diff --git a/react-ui/.editorconfig b/react-ui/.editorconfig new file mode 100644 index 00000000..7e3649ac --- /dev/null +++ b/react-ui/.editorconfig @@ -0,0 +1,16 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/react-ui/.eslintignore b/react-ui/.eslintignore new file mode 100644 index 00000000..3bc705a6 --- /dev/null +++ b/react-ui/.eslintignore @@ -0,0 +1,9 @@ +/lambda/ +/scripts +/config +.history +public +dist +.umi +mock +/src/iconfont/ \ No newline at end of file diff --git a/react-ui/.eslintrc.js b/react-ui/.eslintrc.js new file mode 100644 index 00000000..4d55233b --- /dev/null +++ b/react-ui/.eslintrc.js @@ -0,0 +1,16 @@ +module.exports = { + extends: [ + require.resolve('@umijs/lint/dist/config/eslint'), + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + ], + globals: { + page: true, + REACT_APP_ENV: true, + }, + rules: { + '@typescript-eslint/no-use-before-define': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/display-name': 'off', + }, +}; diff --git a/react-ui/.github/FUNDING.yml b/react-ui/.github/FUNDING.yml new file mode 100644 index 00000000..26b00e2b --- /dev/null +++ b/react-ui/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: ant-design +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/react-ui/.github/ISSUE_TEMPLATE/bug_report.md b/react-ui/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..5cb26924 --- /dev/null +++ b/react-ui/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: '报告 Bug | Report bug 🐛' +about: 报告 Ant Design Pro 的 bug +title: '🐛 [BUG]' +labels: '🐛 bug' +assignees: '' +--- + +### 🐛 bug 描述 + + + +### 📷 复现步骤 | Recurrence steps + + + +### 🏞 期望结果 | Expected results + + + +### 💻 复现代码 | Recurrence code + + + +### © 版本信息 + +- Ant Design Pro 版本: [e.g. 4.0.0] +- umi 版本 +- 浏览器环境 +- 开发环境 [e.g. mac OS] + +### 🚑 其他信息 + + diff --git a/react-ui/.github/ISSUE_TEMPLATE/feature_request.md b/react-ui/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..b2b866a8 --- /dev/null +++ b/react-ui/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: '功能需求 | Feature Requirements ✨' +about: 对 Ant Design Pro 的需求或建议 +title: '👑 [需求 | Feature]' +labels: '👑 Feature Request' +assignees: '' +--- + +### 🥰 需求描述 | Requirements description + + + +### 🧐 解决方案 | Solution + + + +### 🚑 其他信息 | Other information + + diff --git a/react-ui/.github/ISSUE_TEMPLATE/question.md b/react-ui/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 00000000..f0fbe7dd --- /dev/null +++ b/react-ui/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,34 @@ +--- +name: '疑问或需要帮助 | Questions or need help ❓' +about: 对 Ant Design Pro 使用的疑问或需要帮助 +title: '🧐[问题 | question]' +labels: '🧐 question' +assignees: '' +--- + +### 🧐 问题描述 | Problem description + + + +### 💻 示例代码 | Sample code + + + +### 🚑 其他信息 | Other information + + + +OS: + +Node: + +浏览器 | browser: diff --git a/react-ui/.github/workflows/ci.yml b/react-ui/.github/workflows/ci.yml new file mode 100644 index 00000000..d6f5693b --- /dev/null +++ b/react-ui/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: Node CI + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + node_version: [16.x, 14.x] + os: [ubuntu-latest, windows-latest, macOS-latest] + steps: + - uses: actions/checkout@v1 + - name: Use Node.js ${{ matrix.node_version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node_version }} + - run: echo ${{github.ref}} + - run: npm install + - run: yarn run lint + - run: yarn run tsc + - run: yarn run build + env: + CI: true + PROGRESS: none + NODE_ENV: test + NODE_OPTIONS: --max_old_space_size=4096 diff --git a/react-ui/.github/workflows/codeql.yml b/react-ui/.github/workflows/codeql.yml new file mode 100644 index 00000000..705b30e3 --- /dev/null +++ b/react-ui/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "48 12 * * 2" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/react-ui/.github/workflows/coverage.yml b/react-ui/.github/workflows/coverage.yml new file mode 100644 index 00000000..d1457535 --- /dev/null +++ b/react-ui/.github/workflows/coverage.yml @@ -0,0 +1,27 @@ +name: coverage CI + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Use Node.js 16.x + uses: actions/setup-node@v1 + with: + node-version: 16.x + - run: echo ${{github.ref}} + - run: curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7 + - run: pnpm config set store-dir ~/.pnpm-store + - run: pnpm install --strict-peer-dependencies=false + - run: yarn run test:coverage + env: + CI: true + PROGRESS: none + NODE_ENV: test + NODE_OPTIONS: --max_old_space_size=4096 + - run: bash <(curl -s https://codecov.io/bash) diff --git a/react-ui/.github/workflows/emoji-helper.yml b/react-ui/.github/workflows/emoji-helper.yml new file mode 100644 index 00000000..8965a1a2 --- /dev/null +++ b/react-ui/.github/workflows/emoji-helper.yml @@ -0,0 +1,14 @@ +name: Emoji Helper + +on: + release: + types: [published] + +jobs: + emoji: + runs-on: ubuntu-latest + steps: + - uses: actions-cool/emoji-helper@v1.0.0 + with: + type: 'release' + emoji: '+1, laugh, heart, hooray, rocket, eyes' diff --git a/react-ui/.github/workflows/issue-labeled.yml b/react-ui/.github/workflows/issue-labeled.yml new file mode 100644 index 00000000..a3e8b5fc --- /dev/null +++ b/react-ui/.github/workflows/issue-labeled.yml @@ -0,0 +1,37 @@ +name: Issue labeled + +on: + issues: + types: [labeled] + +jobs: + reply-helper: + runs-on: ubuntu-latest + steps: + - name: help wanted + if: github.event.label.name == '❤️ help wanted' || github.event.label.name == '🤝Welcome PR' + uses: actions-cool/issues-helper@v1.11 + with: + actions: 'create-comment' + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.issue.number }} + body: | + Hello @${{ github.event.issue.user.login }}. We totally like your proposal/feedback, welcome to [send us a Pull Request](https://help.github.com/en/articles/creating-a-pull-request) for it. Please provide changelog/TypeScript/documentation/test cases if needed and make sure CI passed, we will review it soon. We appreciate your effort in advance and looking forward to your contribution! + + 你好 @${{ github.event.issue.user.login }},我们完全同意你的提议/反馈,欢迎直接在此仓库 [创建一个 Pull Request](https://help.github.com/en/articles/creating-a-pull-request) 来解决这个问题。请务必提供改动所需相应的 changelog、TypeScript 定义、测试用例、文档等,并确保 CI 通过,我们会尽快进行 Review,提前感谢和期待您的贡献。 + + ![giphy](https://user-images.githubusercontent.com/507615/62342668-4735dc00-b51a-11e9-92a7-d46fbb1cc0c7.gif) + + - name: Need Reproduce + if: github.event.label.name == '🤔 Need Reproduce' + uses: actions-cool/issues-helper@v1.11 + with: + actions: 'create-comment' + token: ${{ secrets.GITHUB_TOKEN }} + issue-number: ${{ github.event.issue.number }} + body: | + Hello @${{ github.event.issue.user.login }}. Please provide a online reproduction by forking this link https://codesandbox.io/ or a minimal GitHub repository. + + 你好 @${{ github.event.issue.user.login }}, 我们需要你提供一个在线的重现实例以便于我们帮你排查问题。你可以通过点击 [此处](https://codesandbox.io/) 创建一个 codesandbox 或者提供一个最小化的 GitHub 仓库。 + + ![](https://gw.alipayobjects.com/zos/antfincdn/y9kwg7DVCd/reproduce.gif) diff --git a/react-ui/.github/workflows/issue-open-check.yml b/react-ui/.github/workflows/issue-open-check.yml new file mode 100644 index 00000000..36442132 --- /dev/null +++ b/react-ui/.github/workflows/issue-open-check.yml @@ -0,0 +1,34 @@ +name: Issue Open Check + +on: + issues: + types: [opened, edited] + +jobs: + check-issue: + runs-on: ubuntu-latest + steps: + - uses: actions-cool/issues-helper@v2.2.0 + id: check + with: + actions: 'check-issue' + issue-number: ${{ github.event.issue.number }} + title-excludes: '🐛 [BUG], 👑 [需求 | Feature], 🧐[问题 | question]' + + - if: steps.check.outputs.check-result == 'false' && github.event.issue.state == 'open' + uses: actions-cool/issues-helper@v2.2.0 + with: + actions: 'create-comment, close-issue' + issue-number: ${{ github.event.issue.number }} + body: | + 当前 Issue 未检测到标题,请规范填写,谢谢! + + The title of the current issue is not detected, please fill in according to the specifications, thank you! + + - if: steps.check.outputs.check-result == 'true' + uses: actions-cool/issues-similarity-analysis@v1 + with: + filter-threshold: 0.8 + title-excludes: '🐛[BUG], 👑 [需求 | Feature], 🧐[问题 | question]' + comment-title: '### 以下的 Issues 可能会帮助到你 / The following issues may help you' + show-footer: false diff --git a/react-ui/.github/workflows/pnpm.yml b/react-ui/.github/workflows/pnpm.yml new file mode 100644 index 00000000..63bb2e45 --- /dev/null +++ b/react-ui/.github/workflows/pnpm.yml @@ -0,0 +1,33 @@ +name: Node pnpm CI + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + node_version: [16.x] + os: [ubuntu-latest, windows-latest, macOS-latest] + steps: + - uses: actions/checkout@v1 + - name: Use Node.js ${{ matrix.node_version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node_version }} + - run: echo ${{github.ref}} + - run: curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7 + - run: pnpm config set store-dir ~/.pnpm-store + - run: pnpm install --strict-peer-dependencies=false + - run: pnpm run lint + - run: pnpm run tsc + - run: pnpm run build + - run: pnpm run test + env: + CI: true + PROGRESS: none + NODE_ENV: test + NODE_OPTIONS: --max_old_space_size=4096 diff --git a/react-ui/.github/workflows/preview-build.yml b/react-ui/.github/workflows/preview-build.yml new file mode 100644 index 00000000..d8667a5d --- /dev/null +++ b/react-ui/.github/workflows/preview-build.yml @@ -0,0 +1,41 @@ +name: Preview Build + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + build-preview: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: build + run: | + yarn + yarn add umi-plugin-pro --save + yarn build + + - name: upload dist artifact + uses: actions/upload-artifact@v2 + with: + name: dist + path: dist/ + retention-days: 5 + + - name: Save PR number + if: ${{ always() }} + run: echo ${{ github.event.number }} > ./pr-id.txt + + - name: Upload PR number + if: ${{ always() }} + uses: actions/upload-artifact@v2 + with: + name: pr + path: ./pr-id.txt diff --git a/react-ui/.github/workflows/preview-deploy.yml b/react-ui/.github/workflows/preview-deploy.yml new file mode 100644 index 00000000..e9a699ff --- /dev/null +++ b/react-ui/.github/workflows/preview-deploy.yml @@ -0,0 +1,100 @@ +name: Preview Deploy + +on: + workflow_run: + workflows: ['Preview Build'] + types: + - completed + +permissions: + contents: read + +jobs: + success: + permissions: + actions: read # for dawidd6/action-download-artifact to query and download artifacts + issues: write # for actions-cool/maintain-one-comment to modify or create issue comments + pull-requests: write # for actions-cool/maintain-one-comment to modify or create PR comments + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + steps: + - name: download pr artifact + uses: dawidd6/action-download-artifact@v2 + with: + workflow: ${{ github.event.workflow_run.workflow_id }} + name: pr + + - name: save PR id + id: pr + run: echo "::set-output name=id::$( + + + body-include: '' + number: ${{ steps.pr.outputs.id }} + + - name: The job failed + if: ${{ failure() }} + uses: actions-cool/maintain-one-comment@v1.2.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + body: | + 😭 Deploy PR Preview failed. + + + + + body-include: '' + number: ${{ steps.pr.outputs.id }} + + failed: + permissions: + actions: read # for dawidd6/action-download-artifact to query and download artifacts + issues: write # for actions-cool/maintain-one-comment to modify or create issue comments + pull-requests: write # for actions-cool/maintain-one-comment to modify or create PR comments + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure' + steps: + - name: download pr artifact + uses: dawidd6/action-download-artifact@v2 + with: + workflow: ${{ github.event.workflow_run.workflow_id }} + name: pr + + - name: save PR id + id: pr + run: echo "::set-output name=id::$( + + + body-include: '' + number: ${{ steps.pr.outputs.id }} diff --git a/react-ui/.github/workflows/preview-start.yml b/react-ui/.github/workflows/preview-start.yml new file mode 100644 index 00000000..39f561d1 --- /dev/null +++ b/react-ui/.github/workflows/preview-start.yml @@ -0,0 +1,24 @@ +name: Preview Start + +on: pull_request_target + +permissions: + contents: read + +jobs: + preview: + permissions: + issues: write # for actions-cool/maintain-one-comment to modify or create issue comments + pull-requests: write # for actions-cool/maintain-one-comment to modify or create PR comments + runs-on: ubuntu-latest + steps: + - name: create + uses: actions-cool/maintain-one-comment@v1.2.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + body: | + ⚡️ Deploying PR Preview... + + + + body-include: '' diff --git a/react-ui/.gitignore b/react-ui/.gitignore new file mode 100644 index 00000000..9d2581cd --- /dev/null +++ b/react-ui/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +**/node_modules +# roadhog-api-doc ignore +/src/utils/request-temp.js +_roadhog-api-doc + +# production +/dist + +# misc +.DS_Store +npm-debug.log* +yarn-error.log + +/coverage +.idea +yarn.lock +package-lock.json +*bak +.vscode + + +# visual studio code +.history +*.log +functions/* +.temp/** + +# umi +.umi +.umi-production +.umi-test + +# screenshot +screenshot +.firebase +.eslintcache + +build + +pnpm-lock.yaml + +*storybook.log diff --git a/react-ui/.husky/commit-msg b/react-ui/.husky/commit-msg new file mode 100644 index 00000000..b04aa8fa --- /dev/null +++ b/react-ui/.husky/commit-msg @@ -0,0 +1,7 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +# Export Git hook params +export GIT_PARAMS=$* + +# npx --no-install fabric verify-commit diff --git a/react-ui/.husky/pre-commit b/react-ui/.husky/pre-commit new file mode 100644 index 00000000..6149072f --- /dev/null +++ b/react-ui/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +# npx --no-install lint-staged diff --git a/react-ui/.npmrc b/react-ui/.npmrc new file mode 100644 index 00000000..dd026c83 --- /dev/null +++ b/react-ui/.npmrc @@ -0,0 +1 @@ +save-prefix=~ diff --git a/react-ui/.nvmrc b/react-ui/.nvmrc new file mode 100644 index 00000000..216afccf --- /dev/null +++ b/react-ui/.nvmrc @@ -0,0 +1 @@ +v18.20.7 diff --git a/react-ui/.prettierignore b/react-ui/.prettierignore new file mode 100644 index 00000000..e726b5a2 --- /dev/null +++ b/react-ui/.prettierignore @@ -0,0 +1,23 @@ +**/*.svg +.umi +.umi-production +/dist +.dockerignore +.DS_Store +.eslintignore +*.png +*.toml +docker +.editorconfig +Dockerfile* +.gitignore +.prettierignore +LICENSE +.eslintcache +*.lock +yarn-error.log +.history +CNAME +/build +/public +/src/iconfont/ diff --git a/react-ui/.prettierrc.js b/react-ui/.prettierrc.js new file mode 100644 index 00000000..3447a1af --- /dev/null +++ b/react-ui/.prettierrc.js @@ -0,0 +1,21 @@ +module.exports = { + singleQuote: true, + trailingComma: 'all', + printWidth: 100, + proseWrap: 'never', + endOfLine: 'lf', + overrides: [ + { + files: '.prettierrc', + options: { + parser: 'json', + }, + }, + { + files: 'document.ejs', + options: { + parser: 'html', + }, + }, + ], +}; diff --git a/react-ui/.storybook/babel-plugin-auto-css-modules.js b/react-ui/.storybook/babel-plugin-auto-css-modules.js new file mode 100644 index 00000000..d24babf5 --- /dev/null +++ b/react-ui/.storybook/babel-plugin-auto-css-modules.js @@ -0,0 +1,15 @@ +export default function (babel) { + const { types: t } = babel; + return { + visitor: { + ImportDeclaration(path) { + const source = path.node.source.value; + if (source.endsWith('.less')) { + if (path.node.specifiers.length > 0) { + path.node.source.value += '?modules'; + } + } + }, + }, + }; +} diff --git a/react-ui/.storybook/blocks/StoryName.tsx b/react-ui/.storybook/blocks/StoryName.tsx new file mode 100644 index 00000000..074c73cb --- /dev/null +++ b/react-ui/.storybook/blocks/StoryName.tsx @@ -0,0 +1,19 @@ +import { Of, useOf } from '@storybook/blocks'; + +/** + * A block that displays the story name or title from the of prop + * - if a story reference is passed, it renders the story name + * - if a meta reference is passed, it renders the stories' title + * - if nothing is passed, it defaults to the primary story + */ +export const StoryName = ({ of }: { of?: Of }) => { + const resolvedOf = useOf(of || 'story', ['story', 'meta']); + switch (resolvedOf.type) { + case 'story': { + return

{resolvedOf.story.name}

; + } + case 'meta': { + return

{resolvedOf.preparedMeta.title}

; + } + } +}; diff --git a/react-ui/.storybook/main.ts b/react-ui/.storybook/main.ts new file mode 100644 index 00000000..c21ab45b --- /dev/null +++ b/react-ui/.storybook/main.ts @@ -0,0 +1,121 @@ +import type { StorybookConfig } from '@storybook/react-webpack5'; +import path from 'path'; +import webpack from 'webpack'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + // '@storybook/addon-webpack5-compiler-swc', + '@storybook/addon-webpack5-compiler-babel', + '@storybook/addon-onboarding', + '@storybook/addon-essentials', + '@chromatic-com/storybook', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + staticDirs: [ + '../public', + { from: '../docs', to: '/docs' }, + { from: '../docs/index.html', to: '/docs/index.html' }, + ], + docs: { + defaultName: 'Documentation', + }, + webpackFinal: async (config) => { + if (config.resolve) { + config.resolve.alias = { + ...config.resolve.alias, + '@': path.resolve(__dirname, '../src'), + '@umijs/max$': path.resolve(__dirname, './mock/umijs.mock.tsx'), + }; + } + if (config.module && config.module.rules) { + config.module.rules.push( + { + test: /\.less$/, + oneOf: [ + { + resourceQuery: /modules/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + importLoaders: 1, + import: true, + esModule: true, + modules: { + localIdentName: '[local]___[hash:base64:5]', + }, + }, + }, + { + loader: 'less-loader', + options: { + lessOptions: { + javascriptEnabled: true, // 如果需要支持 Ant Design 的 Less 变量,开启此项 + modifyVars: { + hack: 'true; @import "@/styles/theme.less";', + }, + }, + }, + }, + ], + include: path.resolve(__dirname, '../src'), // 限制范围,避免处理 node_modules + }, + { + use: [ + 'style-loader', + 'css-loader', + { + loader: 'less-loader', + options: { + lessOptions: { + javascriptEnabled: true, // 如果需要支持 Ant Design 的 Less 变量,开启此项 + modifyVars: { + hack: 'true; @import "@/styles/theme.less";', + }, + }, + }, + }, + ], + include: path.resolve(__dirname, '../src'), // 限制范围,避免处理 node_modules + }, + ], + }, + { + test: /\.(tsx?|jsx?)$/, + loader: 'ts-loader', + options: { + transpileOnly: true, + }, + include: [ + path.resolve(__dirname, '../src'), // 限制范围,避免处理 node_modules + path.resolve(__dirname, './'), + ], + }, + ); + } + if (config.plugins) { + config.plugins.push( + new webpack.ProvidePlugin({ + React: 'react', // 全局注入 React + }), + ); + } + + return config; + }, + babel: async (config: any) => { + if (!config.plugins) { + config.plugins = []; + } + + config.plugins.push(path.resolve(__dirname, './babel-plugin-auto-css-modules.js')); + return config; + }, +}; +export default config; diff --git a/react-ui/.storybook/manager.ts b/react-ui/.storybook/manager.ts new file mode 100644 index 00000000..baf80b25 --- /dev/null +++ b/react-ui/.storybook/manager.ts @@ -0,0 +1,6 @@ +import { addons } from '@storybook/manager-api'; +import theme from './theme'; + +addons.setConfig({ + theme: theme, +}); diff --git a/react-ui/.storybook/mock/umijs.mock.tsx b/react-ui/.storybook/mock/umijs.mock.tsx new file mode 100644 index 00000000..ae8a7646 --- /dev/null +++ b/react-ui/.storybook/mock/umijs.mock.tsx @@ -0,0 +1,19 @@ +export const Link = ({ to, children, ...props }: any) => ( + + {children} + +); + +export const request = (url: string, options: any) => { + return fetch(url, options) + .then((res) => { + if (!res.ok) { + throw new Error(res.statusText); + } + return res; + }) + .then((res) => res.json()); +}; + +export { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +export const history = window.history; diff --git a/react-ui/.storybook/mock/websocket.mock.js b/react-ui/.storybook/mock/websocket.mock.js new file mode 100644 index 00000000..4825c2c4 --- /dev/null +++ b/react-ui/.storybook/mock/websocket.mock.js @@ -0,0 +1,92 @@ +export const createWebSocketMock = () => { + class WebSocketMock { + constructor(url) { + this.url = url; + this.readyState = WebSocket.OPEN; + this.listeners = {}; + this.count = 0; + + console.log('Mock WebSocket connected to:', url); + + // 模拟服务器推送消息 + this.intervalId = setInterval(() => { + this.count += 1; + if (this.count > 5) { + this.count = 0; + clearInterval(this.intervalId); + return; + } + this.sendMessage(JSON.stringify(logStreamData)); + }, 3000); + } + + sendMessage(data) { + if (this.listeners['message']) { + this.listeners['message'].forEach((callback) => callback({ data })); + } + } + + addEventListener(event, callback) { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event].push(callback); + } + + removeEventListener(event, callback) { + if (this.listeners[event]) { + this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback); + } + } + + close() { + this.readyState = WebSocket.CLOSED; + console.log('Mock WebSocket closed'); + } + } + + return WebSocketMock; +}; + +export const logStreamData = { + streams: [ + { + stream: { + workflows_argoproj_io_completed: 'false', + workflows_argoproj_io_workflow: 'workflow-p2ddj', + container: 'init', + filename: + '/var/log/pods/argo_workflow-p2ddj-git-clone-f33abcda-3988047653_e31cf6be-e013-4885-9eb6-ec84f83b9ba9/init/0.log', + job: 'argo/workflow-p2ddj-git-clone-f33abcda-3988047653', + namespace: 'argo', + pod: 'workflow-p2ddj-git-clone-f33abcda-3988047653', + stream: 'stderr', + }, + values: [ + [ + '1742179591969785990', + 'time="2025-03-17T02:46:31.969Z" level=info msg="Starting Workflow Executor" version=v3.5.10\n', + ], + ], + }, + { + stream: { + filename: + '/var/log/pods/argo_workflow-p2ddj-git-clone-f33abcda-3988047653_e31cf6be-e013-4885-9eb6-ec84f83b9ba9/init/0.log', + job: 'argo/workflow-p2ddj-git-clone-f33abcda-3988047653', + namespace: 'argo', + pod: 'workflow-p2ddj-git-clone-f33abcda-3988047653', + stream: 'stderr', + workflows_argoproj_io_completed: 'false', + workflows_argoproj_io_workflow: 'workflow-p2ddj', + container: 'init', + }, + values: [ + [ + '1742179591973414064', + 'time="2025-03-17T02:46:31.973Z" level=info msg="Using executor retry strategy" Duration=1s Factor=1.6 Jitter=0.5 Steps=5\n', + ], + ], + }, + ], +}; diff --git a/react-ui/.storybook/preview.tsx b/react-ui/.storybook/preview.tsx new file mode 100644 index 00000000..0ec22de0 --- /dev/null +++ b/react-ui/.storybook/preview.tsx @@ -0,0 +1,112 @@ +import '@/global.less'; +import '@/overrides.less'; +import themes from '@/styles/theme.less'; +import type { Preview } from '@storybook/react'; +import { App, ConfigProvider } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import { initialize, mswLoader } from 'msw-storybook-addon'; +import { createWebSocketMock } from './mock/websocket.mock'; +import './storybook.css'; + +/* + * Initializes MSW + * See https://github.com/mswjs/msw-storybook-addon#configuring-msw + * to learn how to customize it + */ +initialize(); + +// 替换全局 WebSocket 为 Mock 版本 +// @ts-ignore +global.WebSocket = createWebSocketMock(); + +const preview: Preview = { + parameters: { + controls: { + expanded: true, + sort: 'requiredFirst', + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { + values: [ + { name: 'Dark', value: '#000' }, + { name: 'Gray', value: '#f9fafb' }, + { name: 'Light', value: '#FFF' }, + ], + default: 'Light', + }, + options: { + storySort: { + method: 'alphabetical', + order: ['Documentation', 'Components'], + }, + }, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], + loaders: [mswLoader], // 👈 Add the MSW loader to all stories +}; + +export default preview; diff --git a/react-ui/.storybook/storybook.css b/react-ui/.storybook/storybook.css new file mode 100644 index 00000000..6c592a3c --- /dev/null +++ b/react-ui/.storybook/storybook.css @@ -0,0 +1,19 @@ +html, +body, +#root { + min-width: unset; + height: 100%; + margin: 0; + padding: 0; + overflow-y: visible; +} + +.ant-input-search-large .ant-input-affix-wrapper, .ant-input-search-large .ant-input-search-button { + height: 46px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} diff --git a/react-ui/.storybook/theme.ts b/react-ui/.storybook/theme.ts new file mode 100644 index 00000000..7b624111 --- /dev/null +++ b/react-ui/.storybook/theme.ts @@ -0,0 +1,7 @@ +import { create } from '@storybook/theming'; +export default create({ + base: 'light', + brandTitle: '组件库文档', + brandUrl: 'https://storybook.js.org/docs', + brandTarget: '_blank', +}); diff --git a/react-ui/.storybook/tsconfig.json b/react-ui/.storybook/tsconfig.json new file mode 100644 index 00000000..e30a508b --- /dev/null +++ b/react-ui/.storybook/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "esnext", // 指定ECMAScript目标版本 + "lib": ["dom", "dom.iterable", "esnext"], // 要包含在编译中的库文件列表 + "allowJs": true, // 允许编译JavaScript文件 + "skipLibCheck": true, // 跳过所有声明文件的类型检查 + "esModuleInterop": true, // 禁用命名空间导入(import * as fs from "fs"),并启用CJS/AMD/UMD样式的导入(import fs from "fs") + "allowSyntheticDefaultImports": true, // 允许从没有默认导出的模块进行默认导入 + "strict": true, // 启用所有严格类型检查选项 + "forceConsistentCasingInFileNames": false, // 允许对同一文件的引用使用不一致的大小写 + "module": "esnext", // 指定模块代码生成 + "moduleResolution": "bundler", // 使用bundlers样式解析模块 + "isolatedModules": true, // 无条件地为未解析的文件发出导入 + "resolveJsonModule": true, // 包含.json扩展名的模块 + "noEmit": true, // 不发出输出(即不编译代码,只进行类型检查) + "jsx": "react-jsx", // 在.tsx文件中支持JSX + "sourceMap": true, // 生成相应的.map文件 + "declaration": true, // 生成相应的.d.ts文件 + "noUnusedLocals": true, // 报告未使用的局部变量错误 + "noUnusedParameters": true, // 报告未使用的参数错误 + "incremental": true, // 通过读写磁盘上的文件来启用增量编译 + "noFallthroughCasesInSwitch": true, // 报告switch语句中的fallthrough案例错误 + "strictNullChecks": true, // 启用严格的null检查 + "importHelpers": true, + "baseUrl": "./" + } +} diff --git a/react-ui/.storybook/typings.d.ts b/react-ui/.storybook/typings.d.ts new file mode 100644 index 00000000..742f70c6 --- /dev/null +++ b/react-ui/.storybook/typings.d.ts @@ -0,0 +1,20 @@ +declare module 'slash2'; +declare module '*.css'; +declare module '*.less'; +declare module '*.scss'; +declare module '*.sass'; +declare module '*.svg'; +declare module '*.png'; +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.gif'; +declare module '*.bmp'; +declare module '*.tiff'; +declare module 'omit.js'; +declare module 'numeral'; +declare module '@antv/data-set'; +declare module 'mockjs'; +declare module 'react-fittext'; +declare module 'bizcharts-plugin-slider'; + +declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | false; diff --git a/react-ui/CODE_OF_CONDUCT.md b/react-ui/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..2b4571cb --- /dev/null +++ b/react-ui/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at afc163@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/react-ui/Dockerfile b/react-ui/Dockerfile new file mode 100644 index 00000000..077c862f --- /dev/null +++ b/react-ui/Dockerfile @@ -0,0 +1,3 @@ +# Dockerfile +FROM nginx:alpine +COPY storybook-static/ /usr/share/nginx/html \ No newline at end of file diff --git a/react-ui/LICENSE b/react-ui/LICENSE new file mode 100644 index 00000000..b65958eb --- /dev/null +++ b/react-ui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-present Alipay.inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/react-ui/README.md b/react-ui/README.md new file mode 100644 index 00000000..25f8d456 --- /dev/null +++ b/react-ui/README.md @@ -0,0 +1 @@ +# Documentation diff --git a/react-ui/config/config.ts b/react-ui/config/config.ts new file mode 100644 index 00000000..8d0e45d1 --- /dev/null +++ b/react-ui/config/config.ts @@ -0,0 +1,177 @@ +// https://umijs.org/config/ +import { defineConfig } from '@umijs/max'; +import defaultSettings from './defaultSettings'; +import proxy from './proxy'; +import routes from './routes'; + +const { REACT_APP_ENV = 'dev' } = process.env; + +export default defineConfig({ + /** + * @name 开启 hash 模式 + * @description 让 build 之后的产物包含 hash 后缀。通常用于增量发布和避免浏览器加载缓存。 + * @doc https://umijs.org/docs/api/config#hash + */ + hash: true, + + /** + * @name 兼容性设置 + * @description 设置 ie11 不一定完美兼容,需要检查自己使用的所有依赖 + * @doc https://umijs.org/docs/api/config#targets + */ + // targets: { + // ie: 11, + // }, + /** + * @name 路由的配置,不在路由中引入的文件不会编译 + * @description 只支持 path,component,routes,redirect,wrappers,title 的配置 + * @doc https://umijs.org/docs/guides/routes + */ + // umi routes: https://umijs.org/docs/routing + routes, + /** + * @name 主题的配置 + * @description 虽然叫主题,但是其实只是 less 的变量设置 + * @doc antd的主题设置 https://ant.design/docs/react/customize-theme-cn + * @doc umi 的theme 配置 https://umijs.org/docs/api/config#theme + */ + theme: { + // 如果不想要 configProvide 动态设置主题需要把这个设置为 default + // 只有设置为 variable, 才能使用 configProvide 动态设置主色调 + // 'root-entry-name': 'variable', + }, + /** + * @name moment 的国际化配置 + * @description 如果对国际化没有要求,打开之后能减少js的包大小 + * @doc https://umijs.org/docs/api/config#ignoremomentlocale + */ + ignoreMomentLocale: true, + /** + * @name 代理配置 + * @description 可以让你的本地服务器代理到你的服务器上,这样你就可以访问服务器的数据了 + * @see 要注意以下 代理只能在本地开发时使用,build 之后就无法使用了。 + * @doc 代理介绍 https://umijs.org/docs/guides/proxy + * @doc 代理配置 https://umijs.org/docs/api/config#proxy + */ + proxy: proxy[REACT_APP_ENV as keyof typeof proxy], + /** + * @name 快速热更新配置 + * @description 一个不错的热更新组件,更新时可以保留 state + */ + fastRefresh: true, + //============== 以下都是max的插件配置 =============== + /** + * @name 数据流插件 + * @@doc https://umijs.org/docs/max/data-flow + */ + model: {}, + /** + * 一个全局的初始数据流,可以用它在插件之间共享数据 + * @description 可以用来存放一些全局的数据,比如用户信息,或者一些全局的状态,全局初始状态在整个 Umi 项目的最开始创建。 + * @doc https://umijs.org/docs/max/data-flow#%E5%85%A8%E5%B1%80%E5%88%9D%E5%A7%8B%E7%8A%B6%E6%80%81 + */ + initialState: {}, + /** + * @name layout 插件 + * @doc https://umijs.org/docs/max/layout-menu + */ + title: '智能材料科研平台', + layout: { + ...defaultSettings, + }, + // keepalive: [/./], + // tabsLayout: {}, + /** + * @name moment2dayjs 插件 + * @description 将项目中的 moment 替换为 dayjs + * @doc https://umijs.org/docs/max/moment2dayjs + */ + moment2dayjs: { + preset: 'antd', + plugins: ['duration'], + }, + /** + * @name 国际化插件 + * @doc https://umijs.org/docs/max/i18n + */ + locale: { + default: 'zh-CN', + antd: true, + baseNavigator: true, + }, + /** + * @name antd 插件 + * @description 内置了 babel import 插件 + * @doc https://umijs.org/docs/max/antd#antd + */ + antd: { + configProvider: {}, + appConfig: {}, + }, + /** + * @name 网络请求配置 + * @description 它基于 axios 和 ahooks 的 useRequest 提供了一套统一的网络请求和错误处理方案。 + * @doc https://umijs.org/docs/max/request + */ + request: {}, + /** + * @name 权限插件 + * @description 基于 initialState 的权限插件,必须先打开 initialState + * @doc https://umijs.org/docs/max/access + */ + access: {}, + /** + * @name 中额外的 script + * @description 配置 中额外的 script + */ + headScripts: [ + // 解决首次加载时白屏的问题 + { src: '/scripts/loading.js', async: true }, + { src: '/scripts/resize.js', async: true }, + // { src: '/scripts/resize-breakpoint.js', async: true }, + ], + // links: [ + // { + // href: '/fonts/ALIBABA-PUHUITI-MEDIUM.TTF', + // rel: 'preload', + // as: 'font', + // type: 'font/woff2', + // crossOrigin: 'anonymous', + // }, + // ], + //================ pro 插件配置 ================= + presets: ['umi-presets-pro'], + /** + * @name openAPI 插件的配置 + * @description 基于 openapi 的规范生成serve 和mock,能减少很多样板代码 + * @doc https://pro.ant.design/zh-cn/docs/openapi/ + */ + // openAPI: [], + // mfsu: { + // strategy: 'normal', + // }, + requestRecord: {}, + icons: {}, + lessLoader: { + modifyVars: { + hack: 'true; @import "@/styles/theme.less";', + }, + javascriptEnabled: true, + }, + valtio: {}, + qiankun: { + master: { + sandbox: true, + apps: [ + { + name: 'app1', + entry: '//localhost:7001', + }, + { + name: 'app2', + entry: '//localhost:3000', + }, + ], + }, + }, +}); diff --git a/react-ui/config/defaultSettings.ts b/react-ui/config/defaultSettings.ts new file mode 100644 index 00000000..8129ba1e --- /dev/null +++ b/react-ui/config/defaultSettings.ts @@ -0,0 +1,27 @@ +import { ProLayoutProps } from '@ant-design/pro-components'; + +/** + * @name + */ +const Settings: ProLayoutProps & { + pwa?: boolean; + logo?: string; +} = { + locale: 'zh-CN', + navTheme: 'light', + colorPrimary: '#514cf9', + layout: 'mix', + contentWidth: 'Fluid', + fixedHeader: false, + fixSiderbar: false, + splitMenus: false, + colorWeak: false, + title: '智能材料科研平台', + pwa: true, + token: { + // 参见ts声明,demo 见文档,通过token 修改样式 + //https://procomponents.ant.design/components/layout#%E9%80%9A%E8%BF%87-token-%E4%BF%AE%E6%94%B9%E6%A0%B7%E5%BC%8F + }, +}; + +export default Settings; diff --git a/react-ui/config/proxy.ts b/react-ui/config/proxy.ts new file mode 100644 index 00000000..1ecbb4b4 --- /dev/null +++ b/react-ui/config/proxy.ts @@ -0,0 +1,57 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-17 08:48:09 + * @Description: + */ +/** + * @name 代理的配置 + * @see 在生产环境 代理是无法生效的,所以这里没有生产环境的配置 + * ------------------------------- + * The agent cannot take effect in the production environment + * so there is no configuration of the production environment + * For details, please see + * https://pro.ant.design/docs/deploy + * + * @doc https://umijs.org/docs/guides/proxy + */ +export default { + // 如果需要自定义本地开发服务器 请取消注释按需调整 + dev: { + // localhost:8000/api/** -> https://preview.pro.ant.design/api/** + '/api/': { + // 要代理的地址 + target: 'http://172.20.32.197:31213', // 开发环境 + // target: 'http://172.20.32.235:31213', // 测试环境 + // target: 'http://172.20.32.127:8082', + // target: 'http://172.20.32.164:8082', + // 配置了这个可以从 http 代理到 https + // 依赖 origin 的功能可能需要这个,比如 cookie + changeOrigin: true, + // pathRewrite: { '^/api': '' }, + }, + '/profile/avatar/': { + target: 'http://172.20.32.235:31213', + changeOrigin: true, + }, + }, + + /** + * @name 详细的代理配置 + * @doc https://github.com/chimurai/http-proxy-middleware + */ + test: { + // localhost:8000/api/** -> https://preview.pro.ant.design/api/** + '/api/': { + target: 'https://proapi.azurewebsites.net', + changeOrigin: true, + pathRewrite: { '^': '' }, + }, + }, + pre: { + '/api/': { + target: 'your pre url', + changeOrigin: true, + pathRewrite: { '^': '' }, + }, + }, +}; diff --git a/react-ui/config/routes.ts b/react-ui/config/routes.ts new file mode 100644 index 00000000..133cbbc1 --- /dev/null +++ b/react-ui/config/routes.ts @@ -0,0 +1,651 @@ +/** + * @name umi 的路由配置 + * @description 只支持 path,component,routes,redirect,wrappers,name,icon 的配置 + * @param path path 只支持两种占位符配置,第一种是动态参数 :id 的形式,第二种是 * 通配符,通配符只能出现路由字符串的最后。 + * @param component 配置 location 和 path 匹配后用于渲染的 React 组件路径。可以是绝对路径,也可以是相对路径,如果是相对路径,会从 src/pages 开始找起。 + * @param routes 配置子路由,通常在需要为多个路径增加 layout 组件时使用。 + * @param redirect 配置路由跳转 + * @param wrappers 配置路由组件的包装组件,通过包装组件可以为当前的路由组件组合进更多的功能。 比如,可以用于路由级别的权限校验 + * @param name 配置路由的标题,默认读取国际化文件 menu.ts 中 menu.xxxx 的值,如配置 name 为 login,则读取 menu.ts 中 menu.login 的取值作为标题 + * @param icon 配置路由的图标,取值参考 https://ant.design/components/icon-cn, 注意去除风格后缀和大小写,如想要配置图标为 则取值应为 stepBackward 或 StepBackward,如想要配置图标为 则取值应为 user 或者 User + * @doc https://umijs.org/docs/guides/routes + */ +export default [ + { + path: '/', + redirect: '/home', + }, + { + name: '首页', + path: '/home', + layout: false, + routes: [ + { + name: '首页', + path: '', + key: 'home', + component: './Home/index', + }, + ], + }, + { + name: '工作空间', + path: '/workspace', + routes: [ + { + name: '工作空间', + path: '', + key: 'workspace', + component: './Workspace/index', + }, + { + name: '消息中心', + path: 'message', + key: 'message', + component: './Message/index', + }, + ], + }, + { + path: '/authorize', + layout: false, + component: './Authorize/index', + }, + { + path: '/gitlink', + layout: true, + component: './GitLink/index', + }, + { + path: '/user', + layout: false, + routes: [ + { + name: 'login', + path: '/user/login', + component: process.env.NO_SSO ? './User/Login/login' : './User/Login', + }, + ], + }, + { + path: '/account', + name: '用户中心', + routes: [ + { + name: '用户中心', + path: '/account/center', + component: './User/Center', + }, + { + name: '用户设置', + path: '/account/settings', + component: './User/Settings', + }, + ], + }, + { + name: '数据准备', + path: '/datasetPreparation', + routes: [ + { + path: '', + redirect: '/datasetPreparation/datasetAnnotation', + }, + { + name: '数据标注', + path: 'datasetAnnotation', + component: './DatasetPreparation/DatasetAnnotation/index', + }, + ], + }, + { + name: '开发环境', + path: '/developmentEnvironment', + routes: [ + { + name: '开发环境', + path: '', + component: './DevelopmentEnvironment/List', + }, + { + name: '创建开发环境', + path: 'create', + component: './DevelopmentEnvironment/Create', + }, + { + name: '开发环境详情', + path: 'editor', + component: './DevelopmentEnvironment/Editor', + }, + ], + }, + { + name: '流水线', + path: '/pipeline', + routes: [ + { + path: '', + redirect: '/pipeline/template', + }, + { + name: '流水线模板', + path: '/pipeline/template', + routes: [ + { + name: '流水线模板', + path: '', + component: './Pipeline/index', + }, + { + name: '流水线详情', + path: 'info/:id', + component: './Pipeline/Info/index', + }, + ], + }, + { + name: '实验', + path: 'experiment', + routes: [ + { + name: '实验', + path: '', + component: './Experiment/index', + }, + { + name: '实验实例', + path: 'instance/:workflowId/:id', + component: './Experiment/Info/index', + }, + { + name: '实验对比', + path: 'compare', + routes: [ + { + name: '实验对比', + path: '', + component: './Experiment/Comparison/index', + }, + { + name: '可视化对比', + path: 'compare-visual', + component: './Experiment/Aim/index', + }, + ], + }, + { + name: '可视化', + path: 'visual', + component: './Experiment/Tensorboard/index', + }, + ], + }, + { + name: '自动机器学习', + path: 'automl', + routes: [ + { + name: '自动机器学习', + path: '', + component: './AutoML/List/index', + }, + { + name: '实验详情', + path: 'info/:id', + component: './AutoML/Info/index', + }, + { + name: '创建实验', + path: 'create', + component: './AutoML/Create/index', + }, + { + name: '编辑实验', + path: 'edit/:id', + component: './AutoML/Create/index', + }, + { + name: '复制实验', + path: 'copy/:id', + component: './AutoML/Create/index', + }, + { + name: '实验实例详情', + path: 'instance/:experimentId/:id', + component: './AutoML/Instance/index', + }, + ], + }, + { + name: '超参数自动寻优', + path: 'hyperparameter', + routes: [ + { + name: '超参数寻优', + path: '', + component: './HyperParameter/List/index', + }, + { + name: '实验详情', + path: 'info/:id', + component: './HyperParameter/Info/index', + }, + { + name: '创建实验', + path: 'create', + component: './HyperParameter/Create/index', + }, + { + name: '编辑实验', + path: 'edit/:id', + component: './HyperParameter/Create/index', + }, + { + name: '复制实验', + path: 'copy/:id', + component: './HyperParameter/Create/index', + }, + { + name: '实验实例详情', + path: 'instance/:experimentId/:id', + routes: [ + { + name: '实验实例详情', + path: '', + component: './HyperParameter/Instance/index', + }, + { + name: '可视化对比', + path: 'compare-visual', + component: './HyperParameter/Aim/index', + }, + ], + }, + ], + }, + { + name: '主动学习', + path: 'active-learn', + routes: [ + { + name: '超参数寻优', + path: '', + component: './ActiveLearn/List/index', + }, + { + name: '实验详情', + path: 'info/:id', + component: './ActiveLearn/Info/index', + }, + { + name: '创建实验', + path: 'create', + component: './ActiveLearn/Create/index', + }, + { + name: '编辑实验', + path: 'edit/:id', + component: './ActiveLearn/Create/index', + }, + { + name: '复制实验', + path: 'copy/:id', + component: './ActiveLearn/Create/index', + }, + { + name: '实验实例详情', + path: 'instance/:experimentId/:id', + component: './ActiveLearn/Instance/index', + }, + ], + }, + ], + }, + { + name: 'AI资产', + path: '/dataset', + routes: [ + { + path: '', + redirect: '/dataset/dataset', + }, + { + name: '数据集', + path: 'dataset', + routes: [ + { + name: '数据集', + path: '', + component: './Dataset/index', + }, + { + name: '数据集简介', + path: 'info/:id', + component: './Dataset/intro', + }, + ], + }, + { + name: '模型', + path: 'model', + routes: [ + { + name: '模型', + path: '', + component: './Model/index', + }, + { + name: '模型简介', + path: 'info/:id', + component: './Model/intro', + }, + ], + }, + { + name: '镜像', + path: 'mirror', + routes: [ + { + name: '镜像', + path: '', + component: './Mirror/List', + }, + { + name: '镜像详情', + path: 'info/:id', + routes: [ + { + name: '镜像详情', + path: '', + component: './Mirror/Info', + }, + { + name: '新增镜像版本', + path: 'add-version', + component: './Mirror/Create', + }, + ], + }, + { + name: '创建镜像', + path: 'create', + component: './Mirror/Create', + }, + ], + }, + { + name: '代码配置', + path: 'codeConfig', + routes: [ + { + name: '代码配置', + path: '', + component: './CodeConfig/List', + }, + ], + }, + { + name: '模型部署', + path: 'modelDeployment', + routes: [ + { + name: '模型部署', + path: '', + component: './ModelDeployment/List', + }, + { + name: '创建推理服务', + path: 'createService', + component: './ModelDeployment/CreateService', + }, + { + name: '编辑推理服务', + path: 'editService/:serviceId', + component: './ModelDeployment/CreateService', + }, + { + name: '服务详情', + path: 'serviceInfo/:serviceId', + routes: [ + { + name: '服务详情', + path: '', + component: './ModelDeployment/ServiceInfo', + }, + { + name: '新增服务版本', + path: 'createVersion', + component: './ModelDeployment/CreateVersion', + }, + { + name: '更新服务版本', + path: 'updateVersion', + component: './ModelDeployment/CreateVersion', + }, + { + name: '重启服务版本', + path: 'restartVersion', + component: './ModelDeployment/CreateVersion', + }, + { + name: '服务版本详情', + path: 'versionInfo/:id', + component: './ModelDeployment/VersionInfo', + }, + ], + }, + ], + }, + { + name: '知识图谱', + path: 'knowledge', + routes: [ + { + name: '知识图谱', + path: '', + key: 'knowledge', + component: './Knowledge/index', + }, + ], + }, + ], + }, + { + name: '应用开发', + path: '/appsDeployment', + routes: [ + { + name: '应用开发', + path: '', + key: 'appsDeployment', + component: './Application', + }, + ], + }, + { + name: '监控运维', + path: '/see', + routes: [ + { + name: '监控运维', + path: '', + key: 'see', + component: './missingPage.jsx', + }, + ], + }, + { + name: '资源', + path: '/readad', + routes: [ + { + name: '资源', + path: '', + key: 'readad', + component: './missingPage.jsx', + }, + ], + }, + { + name: '组件', + path: '/compent', + routes: [ + { + name: '组件', + path: '', + key: 'compent', + component: './missingPage.jsx', + }, + ], + }, + { + name: 'monitor', + path: '/monitor', + routes: [ + { + name: '任务日志', + path: '/monitor/job-log/index/:id', + component: './Monitor/JobLog', + }, + ], + }, + { + name: 'tool', + path: '/tool', + routes: [ + { + name: '导入表', + path: '/tool/gen/import', + component: './Tool/Gen/import', + }, + { + name: '编辑表', + path: '/tool/gen/edit', + component: './Tool/Gen/edit', + }, + ], + }, + { + name: '系统管理', + path: '/system', + routes: [ + { + path: '', + redirect: '/system/user', + }, + { + name: '用户管理', + path: 'user', + component: './System/User', + }, + { + name: '角色管理', + path: 'role', + component: './System/Role', + }, + { + name: '定时任务', + path: 'job', + component: './Monitor/Job', + }, + { + name: '菜单管理', + path: 'menu', + component: './System/Menu', + }, + { + name: '部门管理', + path: 'dept', + component: './System/Dept', + }, + { + name: '岗位管理', + path: 'post', + component: './System/Post', + }, + { + name: '字典管理', + path: 'dict', + component: './System/Dict', + }, + { + name: '字典数据', + path: 'dict-data/index/:id', + component: './System/DictData', + }, + { + name: '分配用户', + path: 'role-auth/user/:id', + component: './System/Role/authUser', + }, + { + name: '日志', + path: 'log', + routes: [ + { + path: '', + redirect: '/system/log/operlog', + }, + ], + }, + { + name: '审核管理', + path: 'approval', + component: './System/Approval', + }, + ], + }, + { + name: 'docs', + path: '/docs', + routes: [ + { + name: '使用指南', + path: '', + key: 'docs', + component: './Docs/index', + }, + ], + }, + { + name: 'mixed', + path: '/mixed', + routes: [ + { + name: '父子页面混合', + path: '', + key: 'mixed', + component: './Mixed/index', + }, + ], + }, + { + name: '算力积分', + path: '/points', + routes: [ + { + name: '算力积分', + path: '', + key: 'points', + component: './Points/index', + }, + ], + }, + { + path: '/app1/*', + name: '子应用1', + microApp: 'app1', + layout: true, + }, + { + path: '/app2/*', + name: '子应用2', + microApp: 'app2', + layout: true, + }, + { + path: '*', + layout: false, + component: './404', + }, +]; diff --git a/react-ui/jest.config.ts b/react-ui/jest.config.ts new file mode 100644 index 00000000..2bf149ca --- /dev/null +++ b/react-ui/jest.config.ts @@ -0,0 +1,21 @@ +import { configUmiAlias, createConfig } from '@umijs/max/test'; + +export default async () => { + const config = await configUmiAlias({ + ...createConfig({ + target: 'browser', + }), + }); + return { + ...config, + testEnvironmentOptions: { + ...(config?.testEnvironmentOptions || {}), + url: 'http://localhost:8000', + }, + setupFiles: [...(config.setupFiles || []), './tests/setupTests.jsx'], + globals: { + ...config.globals, + localStorage: null, + }, + }; +}; diff --git a/react-ui/mock/components.ts b/react-ui/mock/components.ts new file mode 100644 index 00000000..ab5b5f1f --- /dev/null +++ b/react-ui/mock/components.ts @@ -0,0 +1,1294 @@ +import { defineMock } from 'umi'; + +export default defineMock({ + 'GET /api/mmp/workflow/235': { + code: 200, + msg: '操作成功', + data: { + id: 233, + name: '分布式训练', + description: 'aa', + dag: { + nodes: [ + { + id: 'git-clone-c0724278', + category_id: 1, + component_name: 'git-clone', + component_label: '代码拉取组件', + task_info: { + image: { + type: 'ref', + item_type: 'image', + label: '镜像', + value: null, + visible: false, + editable: false, + require: 1, + default: '', + condition: '', + description: '克隆代码的镜像', + placeholder: '请选择镜像', + rulers: {}, + }, + working_directory: { + type: 'str', + item_type: '', + label: '工作目录', + value: '', + visible: false, + editable: false, + require: 1, + default: '', + placeholder: '请输入工作目录', + condition: '', + description: '容器内的工作目录', + rulers: {}, + }, + command: { + type: 'str', + item_type: '', + label: '启动命令', + value: '', + visible: false, + editable: false, + require: 1, + default: '', + placeholder: '请输入启动命令', + description: '启动命令,不包括运行参数', + rulers: '', + }, + run_args: { + type: 'map', + item_type: '', + label: '运行参数', + value: [], + visible: false, + editable: false, + require: 0, + default: '', + placeholder: '', + condition: '', + description: '运行命令的参数', + rulers: '', + }, + resources_standard: { + type: 'select', + item_type: 'resource', + label: '资源', + value: {}, + visible: false, + editable: false, + require: 1, + default: '', + placeholder: '', + condition: '', + description: '资源规格', + rulers: {}, + }, + }, + + control_strategy: { + retry_times: { + type: 'str', + item_type: '', + label: '重试次数', + require: 0, + default: '', + placeholder: '', + describe: '任务重试次数', + visible: true, + editable: false, + condition: '', + value: '', + rulers: {}, + }, + max_run_times: { + type: 'str', + item_type: '', + label: '最大运行时间', + require: 0, + default: '', + placeholder: '', + describe: '最大运行时间', + editable: false, + visible: true, + condition: '', + value: '', + rulers: {}, + }, + }, + + in_parameters: { + '--code_config': { + type: 'ref', + item_type: 'code', + label: '代码配置', + require: 1, + default: '', + placeholder: '私有仓库填写ssh地址,公有仓库填写https git地址', + describe: + '代码配置,支持私有仓库和公有仓库,私有仓库填写ssh地址,公有仓库填写https git地址', + editable: false, + visible: true, + condition: '', + value: { + id: 21, + code_repo_name: '原子掺杂识别', + code_repo_vis: 1, + is_public: true, + git_url: 'https://gitlink.org.cn/somunslotus/material-atom-predict.git', + git_branch: 'master', + verify_mode: null, + git_user_name: null, + git_password: null, + ssh_key: null, + create_by: 'admin', + create_time: '2025-03-12T16:46:08.000+08:00', + update_by: 'admin', + update_time: '2025-03-14T14:59:19.000+08:00', + state: 1, + }, + rulers: {}, + showValue: '原子掺杂识别', + fromSelect: true, + }, + }, + + out_parameters: { + '--code_output': { + type: 'str', + item_type: 'code', + label: '代码保存路径', + require: 1, + default: '/code', + editable: false, + visible: true, + placeholder: '代码保存路径', + describe: '代码保存路径', + condition: '', + showValue: '/mycode', + value: '/mycode', + rulers: {}, + fromSelect: false, + }, + }, + + available_range: 0, + description: '代码拉取组件', + icon_path: 'component-icon-1', + create_by: 'admin', + create_time: '2024-09-02T06:08:06.000+08:00', + update_by: 'admin', + update_time: '2024-09-02T06:08:06.000+08:00', + state: 1, + env_variables: [], + x: 612, + y: 215, + label: '代码拉取', + img: '/assets/images/component-icon-1.png', + isCluster: false, + formError: false, + type: 'rect-node', + size: [110, 36], + labelCfg: { + style: { + fill: 'transparent', + fontSize: 0, + boxShadow: '0px 0px 12px rgba(75, 84, 137, 0.05)', + overflow: 'hidden', + x: -20, + y: 0, + textAlign: 'left', + textBaseline: 'middle', + }, + }, + style: { + active: { + fill: 'rgb(247, 250, 255)', + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + }, + selected: { + fill: 'rgb(255, 255, 255)', + stroke: 'rgb(95, 149, 255)', + lineWidth: 4, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + 'text-shape': { + fontWeight: 500, + }, + }, + highlight: { + fill: 'rgb(223, 234, 255)', + stroke: '#4572d9', + lineWidth: 2, + 'text-shape': { + fontWeight: 500, + }, + }, + inactive: { + fill: 'rgb(247, 250, 255)', + stroke: 'rgb(191, 213, 255)', + lineWidth: 1, + }, + disable: { + fill: 'rgb(250, 250, 250)', + stroke: 'rgb(224, 224, 224)', + lineWidth: 1, + }, + fill: '#fff', + stroke: 'transparent', + cursor: 'pointer', + radius: 8, + shadowColor: 'rgba(75, 84, 137, 0.4)', + shadowBlur: 6, + shadowOffsetX: 0, + shadowOffsetY: 0, + overflow: 'hidden', + lineWidth: 0.5, + }, + depth: 0, + }, + { + id: 'model-train-39e9bc7c', + category_id: 2, + component_name: 'model-train', + component_label: '模型训练', + task_info: { + image: { + type: 'ref', + item_type: 'image', + label: '镜像', + value: {}, + visible: true, + editable: true, + require: 1, + default: '', + condition: '', + description: '镜像', + placeholder: '', + rulers: {}, + }, + working_directory: { + type: 'str', + item_type: '', + label: '工作目录', + value: '{{git-clone-c0724278.--code_output}}', + visible: true, + editable: true, + require: 1, + default: '', + placeholder: '', + condition: '', + description: '容器内的工作目录', + rulers: {}, + }, + command: { + type: 'str', + item_type: '', + label: '启动命令', + value: 'conda run -n atom-predict python recognize_dophant/egnn/train_pl_vor.py', + visible: true, + editable: true, + require: 1, + default: '', + placeholder: '', + description: '启动命令,不包括运行参数', + rulers: '', + }, + run_args: { + type: 'map', + item_type: '', + label: '运行参数', + value: [], + visible: true, + editable: true, + require: 0, + default: '', + placeholder: '', + condition: '', + description: '运行命令的参数', + rulers: '', + }, + resources_standard: { + type: 'select', + item_type: 'resource', + label: '资源', + value: { + id: 30, + resource_id: 4, + computing_resource: 'CPU', + standard: { + name: 'CPU', + value: { + gpu: 0, + cpu: 4, + memory: '8GB', + }, + }, + description: 'GPU: 0, CPU:4, 内存: 8GB', + cpu_cores: 4, + memory_gb: 8, + gpu_memory_gb: 0, + gpu_nums: 0, + credit_per_hour: 2.0, + labels: 'accelertor=cpu', + create_by: 'admin', + create_time: '2024-04-19T11:39:40.000+08:00', + update_by: 'admin', + update_time: '2024-04-19T11:39:40.000+08:00', + state: 1, + }, + visible: true, + editable: true, + require: 1, + default: '', + placeholder: '', + condition: '', + description: '资源规格', + rulers: {}, + }, + }, + + control_strategy: { + retry_times: { + type: 'str', + item_type: '', + label: '重试次数', + require: 0, + default: '', + placeholder: '', + describe: '任务重试次数', + visible: true, + editable: true, + condition: '', + value: '', + rulers: {}, + }, + max_run_times: { + type: 'str', + item_type: '', + label: '最大运行时间', + require: 0, + default: '', + placeholder: '', + describe: '最大运行时间', + editable: true, + visible: true, + condition: '', + value: '', + rulers: {}, + }, + }, + in_parameters: { + '--dataset': { + type: 'ref', + item_type: 'dataset', + label: '选择数据集', + require: 1, + default: '', + placeholder: '', + describe: '选择数据集', + condition: '', + visible: true, + editable: true, + value: { + id: '73', + name: '原子掺杂识别场景测试', + version: 'v1', + path: 'fanshuai/datasets/73/fanshuai_dataset_20250519103524/v1/dataset', + identifier: 'fanshuai_dataset_20250519103524', + owner: 'fanshuai', + }, + rulers: {}, + showValue: '原子掺杂识别场景测试:v1', + fromSelect: true, + activeTab: 'Private', + expandedKeys: ['73'], + checkedKeys: ['73-v1'], + }, + '--model_name': { + type: 'ref', + item_type: 'model', + label: '选择模型', + require: 0, + default: '', + placeholder: '', + describe: '最大运行时间', + editable: true, + visible: true, + condition: '', + value: { + id: '39', + name: '原子参杂识别模型', + version: 'v1', + path: 'fanshuai/model/39/fanshuai_model_20250513113514/v1/model', + identifier: 'fanshuai_model_20250513113514', + owner: 'fanshuai', + }, + rulers: {}, + showValue: '原子参杂识别模型:v1', + fromSelect: true, + activeTab: 'Private', + expandedKeys: ['39'], + checkedKeys: ['39-v1'], + }, + }, + out_parameters: { + '--model_output': { + type: 'str', + item_type: '', + label: '模型输出路径', + require: 1, + showValue: '/model', + default: '', + placeholder: '', + describe: '模型输出路径', + editable: true, + visible: true, + rulers: {}, + condition: '', + value: '/model', + fromSelect: false, + }, + }, + available_range: 1, + description: '通用模型训练组件介绍', + icon_path: 'component-icon-2', + create_by: 'admin', + create_time: '2024-05-28T07:33:53.000+08:00', + update_by: 'admin', + update_time: '2024-05-28T07:33:53.000+08:00', + state: 1, + env_variables: [], + x: 596, + y: 348, + label: '模型训练', + img: '/assets/images/component-icon-2.png', + isCluster: false, + formError: false, + type: 'rect-node', + size: [110, 36], + labelCfg: { + style: { + fill: 'transparent', + fontSize: 0, + boxShadow: '0px 0px 12px rgba(75, 84, 137, 0.05)', + overflow: 'hidden', + x: -20, + y: 0, + textAlign: 'left', + textBaseline: 'middle', + }, + }, + style: { + active: { + fill: 'rgb(247, 250, 255)', + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + }, + selected: { + fill: 'rgb(255, 255, 255)', + stroke: 'rgb(95, 149, 255)', + lineWidth: 4, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + 'text-shape': { + fontWeight: 500, + }, + }, + highlight: { + fill: 'rgb(223, 234, 255)', + stroke: '#4572d9', + lineWidth: 2, + 'text-shape': { + fontWeight: 500, + }, + }, + inactive: { + fill: 'rgb(247, 250, 255)', + stroke: 'rgb(191, 213, 255)', + lineWidth: 1, + }, + disable: { + fill: 'rgb(250, 250, 250)', + stroke: 'rgb(224, 224, 224)', + lineWidth: 1, + }, + fill: '#fff', + stroke: 'transparent', + cursor: 'pointer', + radius: 8, + shadowColor: 'rgba(75, 84, 137, 0.4)', + shadowBlur: 6, + shadowOffsetX: 0, + shadowOffsetY: 0, + overflow: 'hidden', + lineWidth: 0.5, + }, + depth: 0, + }, + { + id: 'model-evaluate-c5b68e7c', + category_id: 4, + component_name: 'model-evaluate', + component_label: '模型测试', + task_info: { + image: { + type: 'ref', + item_type: 'image', + label: '镜像', + value: { + id: 15, + image_id: 17, + version: 'v1', + description: null, + url: '172.20.32.187/machine-learning/atom-egnn:v2', + tag_name: 'v1', + file_size: '125MB', + status: 'Available', + create_by: 'fanshuai', + create_time: '2024-04-18T00:00:00.000+08:00', + update_by: 'admin', + update_time: '2024-04-18T00:00:00.000+08:00', + state: 1, + host_ip: null, + }, + visible: false, + editable: false, + require: 0, + default: '', + condition: '', + description: '镜像', + placeholder: '', + rulers: {}, + }, + working_directory: { + type: 'str', + item_type: '', + label: '工作目录', + value: '{{git-clone-c0724278.--code_output}}', + visible: false, + editable: false, + require: 0, + default: '', + placeholder: '', + condition: '', + description: '容器内的工作目录', + rulers: {}, + }, + command: { + type: 'str', + item_type: '', + label: '启动命令', + value: 'conda run -n atom-predict python recognize_dophant/egnn/test_pl_vor.py', + visible: false, + editable: false, + require: 0, + default: '', + placeholder: '', + description: '启动命令,不包括运行参数', + rulers: '', + }, + run_args: { + type: 'map', + item_type: '', + label: '运行参数', + value: [], + visible: false, + editable: false, + require: 0, + default: '', + placeholder: '', + condition: '', + description: '运行命令的参数', + rulers: '', + }, + resources_standard: { + type: 'select', + item_type: 'resource', + label: '资源', + value: { + id: 30, + resource_id: 4, + computing_resource: 'CPU', + standard: { + name: 'CPU', + value: { + gpu: 0, + cpu: 4, + memory: '8GB', + }, + }, + description: 'GPU: 0, CPU:4, 内存: 8GB', + cpu_cores: 4, + memory_gb: 8, + gpu_memory_gb: 0, + gpu_nums: 0, + credit_per_hour: 2.0, + labels: 'accelertor=cpu', + create_by: 'admin', + create_time: '2024-04-19T11:39:40.000+08:00', + update_by: 'admin', + update_time: '2024-04-19T11:39:40.000+08:00', + state: 1, + }, + visible: true, + editable: true, + require: 1, + default: '', + placeholder: '', + condition: '', + description: '资源规格', + rulers: {}, + }, + }, + + control_strategy: { + retry_times: { + type: 'str', + item_type: '', + label: '重试次数', + require: 0, + default: '', + placeholder: '', + describe: '任务重试次数', + visible: true, + editable: true, + condition: '', + value: '', + rulers: {}, + }, + max_run_times: { + type: 'str', + item_type: '', + label: '最大运行时间', + require: 0, + default: '', + placeholder: '', + describe: '最大运行时间', + editable: true, + visible: true, + condition: '', + value: '', + rulers: {}, + }, + }, + + in_parameters: { + '--dataset': { + type: 'ref', + item_type: 'dataset', + label: '选择数据集', + require: 1, + default: '', + placeholder: '', + describe: '选择数据集', + condition: '', + editable: true, + visible: true, + value: { + id: '74', + name: '原子掺杂识别模型测试数据集', + version: 'v2', + path: 'fanshuai/datasets/74/fanshuai_dataset_20250519103749/v2/dataset', + identifier: 'fanshuai_dataset_20250519103749', + owner: 'fanshuai', + }, + rulers: {}, + showValue: '原子掺杂识别模型测试数据集:v2', + fromSelect: true, + activeTab: 'Private', + expandedKeys: ['74'], + checkedKeys: ['74-v2'], + }, + '--model_name': { + type: 'ref', + item_type: 'model', + label: '选择模型', + require: 1, + editable: true, + visible: true, + rulers: {}, + default: '', + placeholder: '', + describe: '这里是这个参数的描述和备注', + condition: '', + value: '{{model-train-39e9bc7c.--model_output}}', + showValue: '{{model-train-39e9bc7c.--model_output}}', + fromSelect: true, + }, + }, + out_parameters: { + '--model_output': { + type: 'str', + item_type: '', + label: '模型测试结果路径', + editable: true, + visible: true, + rulers: {}, + default: '', + placeholder: '', + describe: '这里是这个参数的描述和备注', + condition: '', + require: 1, + showValue: '/result', + value: '/result', + fromSelect: false, + }, + }, + available_range: 1, + description: '模型测试', + icon_path: 'component-icon-4', + create_by: 'admin', + create_time: '2024-05-24T07:00:10.000+08:00', + update_by: 'admin', + update_time: '2024-05-24T07:00:10.000+08:00', + state: 1, + env_variables: [], + x: 600, + y: 460, + label: '模型评估', + img: '/assets/images/component-icon-4.png', + isCluster: false, + formError: false, + type: 'rect-node', + size: [110, 36], + labelCfg: { + style: { + fill: 'transparent', + fontSize: 0, + boxShadow: '0px 0px 12px rgba(75, 84, 137, 0.05)', + overflow: 'hidden', + x: -20, + y: 0, + textAlign: 'left', + textBaseline: 'middle', + }, + }, + style: { + active: { + fill: 'rgb(247, 250, 255)', + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + }, + selected: { + fill: 'rgb(255, 255, 255)', + stroke: 'rgb(95, 149, 255)', + lineWidth: 4, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + 'text-shape': { + fontWeight: 500, + }, + }, + highlight: { + fill: 'rgb(223, 234, 255)', + stroke: '#4572d9', + lineWidth: 2, + 'text-shape': { + fontWeight: 500, + }, + }, + inactive: { + fill: 'rgb(247, 250, 255)', + stroke: 'rgb(191, 213, 255)', + lineWidth: 1, + }, + disable: { + fill: 'rgb(250, 250, 250)', + stroke: 'rgb(224, 224, 224)', + lineWidth: 1, + }, + fill: '#fff', + stroke: 'transparent', + cursor: 'pointer', + radius: 8, + shadowColor: 'rgba(75, 84, 137, 0.4)', + shadowBlur: 6, + shadowOffsetX: 0, + shadowOffsetY: 0, + overflow: 'hidden', + lineWidth: 0.5, + }, + depth: 0, + }, + { + id: 'model-export-edcf438e', + category_id: 6, + component_name: 'model-export', + component_label: '模型导出', + task_info: { + image: { + type: 'ref', + item_type: 'image', + label: '镜像', + value: {}, + visible: true, + editable: true, + require: 1, + default: '', + condition: '', + description: '镜像', + placeholder: '', + rulers: {}, + }, + working_directory: { + type: 'str', + item_type: '', + label: '工作目录', + value: '{{git-clone-c0724278.--code_output}}', + visible: true, + editable: true, + require: 1, + default: '', + placeholder: '', + condition: '', + description: '容器内的工作目录', + rulers: {}, + }, + command: { + type: 'str', + item_type: '', + label: '启动命令', + value: 'conda run -n atom-predict python recognize_dophant/egnn/test_pl_vor.py', + visible: true, + editable: true, + require: 1, + default: '', + placeholder: '', + description: '启动命令,不包括运行参数', + rulers: '', + }, + run_args: { + type: 'map', + item_type: '', + label: '运行参数', + value: [], + visible: true, + editable: true, + require: 0, + default: '', + placeholder: '', + condition: '', + description: '运行命令的参数', + rulers: '', + }, + resources_standard: { + type: 'select', + item_type: 'resource', + label: '资源', + value: {}, + visible: false, + editable: false, + require: 1, + default: '', + placeholder: '', + condition: '', + description: '资源规格', + rulers: {}, + }, + }, + control_strategy: { + retry_times: { + type: 'str', + item_type: '', + label: '重试次数', + require: 0, + default: '', + placeholder: '', + describe: '任务重试次数', + visible: true, + editable: true, + condition: '', + value: '', + rulers: {}, + }, + max_run_times: { + type: 'str', + item_type: '', + label: '最大运行时间', + require: 0, + default: '', + placeholder: '', + describe: '最大运行时间', + editable: true, + visible: true, + condition: '', + value: '', + rulers: {}, + }, + }, + + in_parameters: { + '--model_source': { + type: 'str', + item_type: '', + label: '模型来源', + require: 1, + default: '', + placeholder: '模型来源', + describe: '模型来源', + editable: true, + visible: true, + condition: '', + value: '{{model-train-39e9bc7c.--model_output}}', + rulers: {}, + fromSelect: true, + showValue: '{{model-train-39e9bc7c.--model_output}}', + }, + '--model_id': { + type: 'select', + item_type: 'model', + label: '导出到模型', + require: 1, + default: '', + placeholder: '', + describe: '导出到模型', + editable: true, + visible: true, + condition: '', + value: { + id: '76', + name: '原子掺杂识别模型场景测试', + identifier: 'fanshuai_model_20250519105223', + owner: 'fanshuai', + }, + rulers: {}, + }, + '--version': { + type: 'str', + item_type: '', + label: '模型版本', + require: 1, + choice: [], + default: '1', + placeholder: '', + describe: '模型版本', + editable: false, + condition: '', + showValue: '${model_version}', + value: '${model_version}', + fromSelect: false, + }, + '--description': { + type: 'str', + item_type: '', + label: '版本描述', + require: 1, + choice: [], + default: '', + placeholder: '版本描述', + describe: '版本描述', + editable: false, + condition: '', + showValue: '流水线自动导出', + value: '流水线自动导出', + fromSelect: false, + }, + }, + available_range: 0, + description: '模型导出', + icon_path: 'component-icon-8', + create_by: 'admin', + create_time: '2024-05-29T01:12:01.000+08:00', + update_by: 'admin', + update_time: '2024-05-29T09:11:55.000+08:00', + state: 1, + env_variables: [], + x: 592, + y: 581, + label: '模型导出', + img: '/assets/images/component-icon-8.png', + isCluster: false, + formError: false, + type: 'rect-node', + size: [110, 36], + labelCfg: { + style: { + fill: 'transparent', + fontSize: 0, + boxShadow: '0px 0px 12px rgba(75, 84, 137, 0.05)', + overflow: 'hidden', + x: -20, + y: 0, + textAlign: 'left', + textBaseline: 'middle', + }, + }, + style: { + active: { + fill: 'rgb(247, 250, 255)', + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + }, + selected: { + fill: 'rgb(255, 255, 255)', + stroke: 'rgb(95, 149, 255)', + lineWidth: 4, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + 'text-shape': { + fontWeight: 500, + }, + }, + highlight: { + fill: 'rgb(223, 234, 255)', + stroke: '#4572d9', + lineWidth: 2, + 'text-shape': { + fontWeight: 500, + }, + }, + inactive: { + fill: 'rgb(247, 250, 255)', + stroke: 'rgb(191, 213, 255)', + lineWidth: 1, + }, + disable: { + fill: 'rgb(250, 250, 250)', + stroke: 'rgb(224, 224, 224)', + lineWidth: 1, + }, + fill: '#fff', + stroke: 'transparent', + cursor: 'pointer', + radius: 8, + shadowColor: 'rgba(75, 84, 137, 0.4)', + shadowBlur: 6, + shadowOffsetX: 0, + shadowOffsetY: 0, + overflow: 'hidden', + lineWidth: 0.5, + }, + depth: 0, + }, + ], + edges: [ + { + source: 'git-clone-c0724278', + target: 'model-train-39e9bc7c', + style: { + endArrow: { + path: 'M 6,0 L 9,-1.5 L 9,1.5 Z', + d: 4.5, + fill: '#CDD0DC', + }, + cursor: 'pointer', + lineWidth: 1, + lineAppendWidth: 4, + opacity: 1, + stroke: '#CDD0DC', + radius: 1, + active: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 1, + }, + selected: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + 'text-shape': { + fontWeight: 500, + }, + }, + highlight: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + 'text-shape': { + fontWeight: 500, + }, + }, + inactive: { + stroke: 'rgb(234, 234, 234)', + lineWidth: 1, + }, + disable: { + stroke: 'rgb(245, 245, 245)', + lineWidth: 1, + }, + }, + labelCfg: { + autoRotate: true, + style: { + fontSize: 10, + fill: '#FFF', + }, + }, + id: 'edge-0.163955357654560491741769193740', + startPoint: { + x: 612, + y: 233.25, + anchorIndex: 1, + }, + endPoint: { + x: 596, + y: 329.75, + anchorIndex: 0, + }, + sourceAnchor: 1, + targetAnchor: 0, + type: 'cubic-vertical', + curvePosition: [0.5, 0.5], + minCurveOffset: [0, 0], + depth: 0, + }, + { + source: 'model-train-39e9bc7c', + target: 'model-evaluate-c5b68e7c', + style: { + endArrow: { + path: 'M 6,0 L 9,-1.5 L 9,1.5 Z', + d: 4.5, + fill: '#CDD0DC', + }, + cursor: 'pointer', + lineWidth: 1, + lineAppendWidth: 4, + opacity: 1, + stroke: '#CDD0DC', + radius: 1, + active: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 1, + }, + selected: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + 'text-shape': { + fontWeight: 500, + }, + }, + highlight: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + 'text-shape': { + fontWeight: 500, + }, + }, + inactive: { + stroke: 'rgb(234, 234, 234)', + lineWidth: 1, + }, + disable: { + stroke: 'rgb(245, 245, 245)', + lineWidth: 1, + }, + }, + labelCfg: { + autoRotate: true, + style: { + fontSize: 10, + fill: '#FFF', + }, + }, + id: 'edge-0.150681812754051241741769198063', + startPoint: { + x: 596, + y: 366.25, + anchorIndex: 1, + }, + endPoint: { + x: 600, + y: 441.75, + anchorIndex: 0, + }, + sourceAnchor: 1, + targetAnchor: 0, + type: 'cubic-vertical', + curvePosition: [0.5, 0.5], + minCurveOffset: [0, 0], + depth: 0, + }, + { + source: 'model-evaluate-c5b68e7c', + target: 'model-export-edcf438e', + style: { + endArrow: { + path: 'M 6,0 L 9,-1.5 L 9,1.5 Z', + d: 4.5, + fill: '#CDD0DC', + }, + cursor: 'pointer', + lineWidth: 1, + lineAppendWidth: 4, + opacity: 1, + stroke: '#CDD0DC', + radius: 1, + active: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 1, + }, + selected: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + shadowColor: 'rgb(95, 149, 255)', + shadowBlur: 10, + 'text-shape': { + fontWeight: 500, + }, + }, + highlight: { + stroke: 'rgb(95, 149, 255)', + lineWidth: 2, + 'text-shape': { + fontWeight: 500, + }, + }, + inactive: { + stroke: 'rgb(234, 234, 234)', + lineWidth: 1, + }, + disable: { + stroke: 'rgb(245, 245, 245)', + lineWidth: 1, + }, + }, + labelCfg: { + autoRotate: true, + style: { + fontSize: 10, + fill: '#FFF', + }, + }, + id: 'edge-0.40652053406360491741769205906', + startPoint: { + x: 600, + y: 478.25, + anchorIndex: 1, + }, + endPoint: { + x: 592, + y: 562.75, + anchorIndex: 0, + }, + sourceAnchor: 1, + targetAnchor: 0, + type: 'cubic-vertical', + curvePosition: [0.5, 0.5], + minCurveOffset: [0, 0], + depth: 0, + }, + ], + combos: [], + }, + global_param: null, + create_by: 'fanshuai', + create_time: '2025-06-19T19:50:47.000+08:00', + update_by: 'fanshuai', + update_time: '2025-06-24T09:28:28.000+08:00', + state: 1, + }, + }, +}); diff --git a/react-ui/mock/home.ts b/react-ui/mock/home.ts new file mode 100644 index 00000000..25039391 --- /dev/null +++ b/react-ui/mock/home.ts @@ -0,0 +1,600 @@ +import { defineMock } from 'umi'; + +export default defineMock({ + 'GET /api/mmp/workspace/getPublicDatasets': { + msg: '操作成功', + code: 200, + data: { + content: [ + { + name: 'R1蒸馏模型数学推理能力测试集', + identifier: 'public_dataset_20250519163052', + description: + '共728道数学推理题目,包括:\nMATH-500:一组具有挑战性的高中数学竞赛问题数据集,涵盖七个科目(如初等代数、代数、数论)共500道题。\nGPQA-Diamond:该数据集包含物理、化学和生物学子领域的硕士水平多项选择题,共198道题。\nAIME-2024:美国邀请数学竞赛的数据集,包含30道数学题。', + is_public: true, + time_ago: '2个月前', + full_last_update_time: '2025-06-23T14:36:48.000+08:00', + id: 91, + visits: 1, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '有机化学VLM', + identifier: 'public_dataset_20250527113008', + description: 'Dataset Card for "Chemistry_text_to_image"', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:06:32.000+08:00', + id: 134, + visits: 2, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'OQMD 开源量子材料数据集', + identifier: 'public_dataset_20250527141950', + description: + 'QMD 包含了通过密度泛函理论 (DFT) 计算得到的超过 1,226,781 种材料的热力学和结构性质。数据库中的数据来源于无机晶体结构数据库 (ICSD),包括了近 300,000 种化合物的 DFT 总能量计算以及常见晶体结构的修饰', + is_public: true, + time_ago: '2个月前', + full_last_update_time: '2025-06-23T14:38:43.000+08:00', + id: 136, + visits: 5, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '不可降解和可生物降解的材料数据集', + identifier: 'public_dataset_20250527142930', + description: + '此数据集包含大约 256K 图像(156K 原始数据),代表两类:可生物降解和不可生物降解。\n可生物降解,包含可被微生物自然分解的材料,如食物、植物、水果等。这种材料的废物可以加工成堆肥。\n不可生物降解,包含无法自然分解的材料,例如塑料、金属、无机元素等。这种材料的废料将被回收成新材料。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:06:11.000+08:00', + id: 137, + visits: 2, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '金属有机框架材料预测', + identifier: 'public_dataset_20250527143028', + description: + '金属有机框架 (MOF) 是一类通过金属离子(或金属簇)和有机配体之间的配位键连接的结晶材料。MOF 材料具有多孔结构、高度可调和巨大的比表面积,使其在吸附、储气、分离、催化等领域具有广泛的应用潜力。预测合成是指通过计算机模拟和机器学习方法对新型 MOF 材料的合成路线和条件进行预测和设计。\n', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:06:01.000+08:00', + id: 138, + visits: 2, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '纤维增强复合材料的弹性特性', + identifier: 'public_dataset_20250527144649', + description: + '纤维增强复合材料弹性特性数据集主要包含其力学性能参数,如弹性模量(纵向、横向)、剪切模量、泊松比以及应力-应变关系等。数据通常通过实验测试(拉伸、压缩、弯曲试验)或计算模拟(有限元分析、细观力学模型)获得,涵盖不同纤维类型(碳纤维、玻璃纤维、芳纶等)、基体材料(环氧树脂、热塑性塑料等)及铺层方式(单向、编织、多轴向)的组合。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:05:24.000+08:00', + id: 142, + visits: 2, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'OCR 合成材料', + identifier: 'public_dataset_20250527144821', + description: + 'OCR(光学字符识别)合成材料数据集是用于训练和评估文本识别模型的专用数据集,主要包含人工生成的文本图像,模拟真实场景中的材料标签、说明书、包装文字等。这类数据集通常涵盖多种字体、背景、光照条件、扭曲变形及噪声干扰,以提高模型鲁棒性。数据可能包含金属、塑料、复合材料等工业材料的名称、参数(如成分、规格、批次号)及安全标识', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:05:18.000+08:00', + id: 143, + visits: 2, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '钙钛矿稳定性', + identifier: 'public_dataset_20250527145953', + description: + '这个钙钛矿稳定性数据集给出了潜在钙钛矿材料成分相对于用 DFT 计算的凸包的能量。钙钛矿数据集还包括包含钙钛矿结构中 A 位点、B 位点和 X 位点信息的列,以便对数据进行更高级的分组。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:04:30.000+08:00', + id: 148, + visits: 2, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '纳米颗粒毒性数据集', + identifier: 'public_dataset_20250527150856', + description: + '该数据集是一个毒性数据集,由几列组成,捕获了纳米颗粒 (NPs) 的各种属性及其毒理学影响。该数据集包含与纳米颗粒 (NPs) 及其特性相关的各种特征,这些特征可能与毒性分类有关', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:04:41.000+08:00', + id: 149, + visits: 2, + praises_count: 1, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '3D多模态医疗数据集-分割-fanshuai', + identifier: 'public_dataset_20250519151852', + description: '大规模通用 3D 医疗图像分割数据集 (M3D-Seg)', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-22T10:20:52.000+08:00', + id: 82, + visits: 0, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '中文基于满血DeepSeek-R1蒸馏数据集', + identifier: 'public_dataset_20250519161406', + description: + '注意:提供了直接SFT使用的版本。将数据中的思考和答案整合成output字段,大部分SFT代码框架均可直接直接加载训练。\n本数据集为中文开源蒸馏满血R1的数据集,数据集中不仅包含math数据,还包括大量的通用类型数据,总数量为110K。\n为什么开源这个数据?\nR1的效果十分强大,并且基于R1蒸馏数据SFT的小模型也展现出了强大的效果,但检索发现,大部分开源的R1蒸馏数据集均为英文数', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-19T16:14:06.000+08:00', + id: 88, + visits: 0, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: '中文Text2SQL数据集', + identifier: 'public_dataset_20250519165142', + description: + '同时包含用于训练和测试表格问答预训练模型的数据,数据集包含500条训练数据和100条测试数据。\n表格问答预训练模型的训练和测试数据,支持中文,支持通用领域的表格问答。另外,也可以从本model card中,点击数据集文件panel,然后点击数据文件选项,即可下载trian.zip和test.zip文件', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-19T16:51:43.000+08:00', + id: 93, + visits: 0, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'MatPES', + identifier: 'public_dataset_20250521090336', + description: + '使用元素周期表几乎完全覆盖的势能面数据集来训练基础 电位 (FP),即机器学习原子间电位 (MLIP),几乎完全覆盖了周期性 桌子。MatPES 是材料虚拟实验室和材料项目的一项倡议,旨在解决此类材料 PES 数据集中的关键缺陷。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:10:21.000+08:00', + id: 100, + visits: 2, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'innovation_contest/innov202305100905418', + identifier: 'public_dataset_20250526093119', + description: + '1. 赛题解读PPT;2.根据流动状态分开的数据集,方便选手测试自己模型的变状态泛化性能\n新的数据中输入输出与均在一个文件夹中\n新的数据集中,模型文件简称对应的状态如下:\nCBFS 曲线后台阶 雷诺数Re=13700\nCDN 收缩扩张管道 Re=12600\nduct 方管 Re在文件名中包含,比如duct_Re1100.csv代表Re=1100\nperhill 周期山 Re=5600,文件名后面', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:08:27.000+08:00', + id: 122, + visits: 3, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'WIDERFace', + identifier: 'public_dataset_20250526094839', + description: + '32,203张图像,并对393,703张像样本图像中所描述的在尺度、姿势和遮挡方面具有高度可变性的面孔进行标记。较宽的人脸数据集基于61个事件类进行组织。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:08:18.000+08:00', + id: 123, + visits: 2, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'StanfordSentimentTreebank', + identifier: 'public_dataset_20250526095521', + description: + '用于情感分析的数据集,其中包含11855个句子的语法分析树中215154个短语的细粒度情感标签,并为情感组成提出了新挑战。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:08:12.000+08:00', + id: 124, + visits: 2, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'COCO', + identifier: 'public_dataset_20250526100341', + description: + 'COCO是大规模的对象检测,分割和字幕数据集。 它包含:330K图像(标为> 200K),150万个对象实例,80个对象类别。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:07:56.000+08:00', + id: 125, + visits: 2, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'BillionWords', + identifier: 'public_dataset_20250526101006', + description: '该项目的目的是为语言建模实验提供标准的培训和测试设置,包含10亿字。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:07:47.000+08:00', + id: 126, + visits: 2, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'car_ims', + identifier: 'public_dataset_20250527084401', + description: + '斯坦福汽车数据集包含196类汽车的16,185张图像。数据被分为8,144个训练图像和8,041个测试图像,其中每个类别已大致分为50-50个分割。', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:07:20.000+08:00', + id: 128, + visits: 2, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + { + name: 'IU-xrays', + identifier: 'public_dataset_20250527093542', + description: '放射图像', + is_public: true, + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:07:12.000+08:00', + id: 129, + visits: 2, + praises_count: 0, + create_by: 'fanshuai', + owner: 'fanshuai', + }, + ], + pageable: { + sort: { + sorted: false, + unsorted: true, + empty: true, + }, + pageNumber: 0, + pageSize: 20, + offset: 0, + unpaged: false, + paged: true, + }, + last: false, + totalElements: 39, + totalPages: 2, + first: true, + number: 0, + sort: { + sorted: false, + unsorted: true, + empty: true, + }, + numberOfElements: 20, + size: 20, + empty: false, + }, + }, + 'GET /api/mmp/workspace/getPublicModels': { + msg: '操作成功', + code: 200, + data: { + content: [ + { + id: 109, + name: '介电', + create_by: 'ceshi', + description: '介电材料模型', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:09:54.000+08:00', + owner: 'ceshi', + identifier: 'public_model_20250522110231', + is_public: true, + praises_count: 2, + }, + { + id: 156, + name: 'ChatGLM2-6B', + create_by: 'fanshuai', + description: + 'ChatGLM2-6B 是开源中英双语对话模型 ChatGLM-6B 的第二代版本,在保留了初代模型对话流畅、部署门槛较低等众多优秀特性的基础之上', + time_ago: '2个月前', + full_last_update_time: '2025-06-20T16:09:02.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528093916', + is_public: true, + praises_count: 1, + }, + { + id: 155, + name: '鹏城·脑海(原鹏城·盘古)α-2.6B-CPU', + create_by: 'fanshuai', + description: + '「鹏城·盘古α」由以鹏城实验室为首的技术团队联合攻关,首次基于“鹏城云脑Ⅱ”和国产MindSpore框架的自动混合并行模式实现在2048卡算力集群上的大规模分布式训练,训练出业界首个2000亿参数以中文为核心的预训练生成语言模型。鹏城·盘古α预训练模型支持丰富的场景应用,在知识问答、知识检索、知识推理、阅读理解等文本生成领域表现突出,具备很强的小样本学习能力。', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:03:36.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528093254', + is_public: true, + praises_count: 0, + }, + { + id: 157, + name: 'ernie-3.0-base-zh', + create_by: 'fanshuai', + description: '大规模知识增强预训练,用于语言理解和生成', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:03:08.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528094825', + is_public: true, + praises_count: 0, + }, + { + id: 158, + name: 'FastChat-T5', + create_by: 'fanshuai', + description: + 'FastChat-T5是一款开源聊天机器人,通过微调Flan-t5-xl (3B参数)并基于从ShareGPT.收集的用户共享对话进行训练。它基于编码器-解码器变压器架构,能够自回归生成对用户输入的响应。', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:03:19.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528101831', + is_public: true, + praises_count: 0, + }, + { + id: 159, + name: 'Kolors-IP-Adapter-Plus', + create_by: 'fanshuai', + description: '基于Kolors-Basemodel提供了IP-Adapter-Plus的权重和推理代码', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:03:01.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528102217', + is_public: true, + praises_count: 0, + }, + { + id: 160, + name: 'Florence-2-base', + create_by: 'fanshuai', + description: + 'Florence-2是一款先进的视觉基础模型,采用提示式方法处理广泛的视觉和视觉-语言任务。Florence-2能够通过简单的文本提示来执行诸如字幕生成、物体检测和分割等任务。该模型利用了包含54亿个注释的FLD-5B数据集,这些注释覆盖了1.26亿张图像,从而掌握了多任务学习。模型的序列到序列架构使其在零样本和微调设置中表现出色,证明了其作为竞争性视觉基础模型的实力。', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:02:55.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528102750', + is_public: true, + praises_count: 0, + }, + { + id: 161, + name: 'E5-base', + create_by: 'fanshuai', + description: '弱监督对比预训练的文本嵌入。', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:02:48.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528103059', + is_public: true, + praises_count: 0, + }, + { + id: 162, + name: 'Mini-InternVL-Chat', + create_by: 'fanshuai', + description: + '使用了与InternVL 1相同的数据来训练这个较小的模型。此外,由于较小模型的训练成本较低,我们在训练时采用了8K的上下文长度。', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:02:39.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528105945', + is_public: true, + praises_count: 0, + }, + { + id: 163, + name: 'Verdict-Classifier', + create_by: 'fanshuai', + description: + '该模型是基于xlm-roberta-base的微调版本,基于谷歌事实核查工具API提供的2,500条去重多语言判决,并通过谷歌云翻译API转换成65种语言', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:02:27.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528110545', + is_public: true, + praises_count: 0, + }, + { + id: 164, + name: 'Text2Vec-Base-Multilingual', + create_by: 'fanshuai', + description: + '这是一个CoSENT(余弦句子)模型,它将句子映射到一个384维的密集向量空间,并可用于任务,例如句子嵌入、文本匹配或语义搜索。', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:02:19.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528110858', + is_public: true, + praises_count: 0, + }, + { + id: 167, + name: 'Latex-OCR', + create_by: 'fanshuai', + description: '识别图像中的数学公式并转换为Latex源码。', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T18:02:09.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528112153', + is_public: true, + praises_count: 0, + }, + { + id: 169, + name: 'XLNet', + create_by: 'fanshuai', + description: + 'XLNet是一种基于新型广义置换语言建模目标的新型无监督语言表示学习方法。此外,XLNet采用Transformer-XL作为骨干模型,在处理长上下文的语言任务中表现出色。总体而言,XLNet在包括问答、自然语言推理、情感分析和文档排序在内的多种下游语言任务中取得了最先进的(SOTA)成果。', + time_ago: '3个月前', + full_last_update_time: '2025-05-28T17:29:05.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250528172905', + is_public: true, + praises_count: 0, + }, + { + id: 173, + name: ' GTE-base', + create_by: 'fanshuai', + description: + 'GTE模型由阿里巴巴达摩学院训练。这些模型主要基于BERT框架,目前提供三种不同规模的版本,分别是GTE-large、GTE-base和GTE-small。GTE模型在大规模的相关文本对语料库上进行训练,涵盖了广泛的领域和场景。这使得GTE模型能够应用于文本嵌入的多种下游任务,如信息检索、语义文本相似性分析、文本重排序等。', + time_ago: '3个月前', + full_last_update_time: '2025-05-29T09:14:15.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250529091415', + is_public: true, + praises_count: 0, + }, + { + id: 174, + name: 'Tiny-lm', + create_by: 'fanshuai', + description: + '此仓库提供了一个小型的1600万参数语言模型,该模型基于英文和日文维基百科数据训练。', + time_ago: '3个月前', + full_last_update_time: '2025-05-29T09:18:57.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250529091857', + is_public: true, + praises_count: 0, + }, + { + id: 175, + name: "Snowflake's Arctic-embed-s", + create_by: 'fanshuai', + description: + 'snowflake-arctic-embed是一套文本嵌入模型,专注于创建高性能的高质量检索模型。', + time_ago: '3个月前', + full_last_update_time: '2025-05-29T09:23:58.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250529092358', + is_public: true, + praises_count: 0, + }, + { + id: 176, + name: 'ViTMatte model', + create_by: 'fanshuai', + description: + 'ViTMatte是一种简单的图像抠图方法,旨在准确估计图像中的前景物体。该模型由一个Vision Transformer(ViT)和一个轻量级头部组成。', + time_ago: '3个月前', + full_last_update_time: '2025-05-29T09:41:07.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250529094107', + is_public: true, + praises_count: 0, + }, + { + id: 177, + name: 'Wartortle', + create_by: 'fanshuai', + description: '此模型专为语义自动补全功能而设计。', + time_ago: '3个月前', + full_last_update_time: '2025-05-29T09:44:43.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250529094443', + is_public: true, + praises_count: 0, + }, + { + id: 179, + name: 'Cerebras-GPT', + create_by: 'fanshuai', + description: + 'Cerebras-GPT系列的发布旨在通过开放架构和数据集促进对大型语言模型(LLM)扩展规律的研究,并展示在Cerebras软硬件栈上训练LLM的简便性和可扩展性。所有Cerebras-GPT模型均可在Hugging Face.上获取。', + time_ago: '3个月前', + full_last_update_time: '2025-05-29T10:01:52.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250529100152', + is_public: true, + praises_count: 0, + }, + { + id: 180, + name: 'Qwen2-1.5B-Instruct-AWQ', + create_by: 'fanshuai', + description: + 'Qwen2是Qwen大语言模型系列的最新成员。我们为Qwen2推出了多个基础语言模型和指令调优语言模型,参数规模从0.5亿到72亿不等,其中包括一个专家混合模型。本仓库包含1.5亿参数的指令调优Qwen2模型。', + time_ago: '3个月前', + full_last_update_time: '2025-05-29T10:13:53.000+08:00', + owner: 'fanshuai', + identifier: 'public_model_20250529101353', + is_public: true, + praises_count: 0, + }, + ], + pageable: { + sort: { + sorted: false, + unsorted: true, + empty: true, + }, + pageNumber: 0, + pageSize: 20, + offset: 0, + unpaged: false, + paged: true, + }, + last: false, + totalElements: 28, + totalPages: 2, + first: true, + number: 0, + sort: { + sorted: false, + unsorted: true, + empty: true, + }, + numberOfElements: 20, + size: 20, + empty: false, + }, + }, +}); diff --git a/react-ui/mock/model.ts b/react-ui/mock/model.ts new file mode 100644 index 00000000..af637db0 --- /dev/null +++ b/react-ui/mock/model.ts @@ -0,0 +1,490 @@ +import { defineMock } from 'umi'; + +export default defineMock({ + 'POST /api/mmp/modelDependency/queryModelAtlas': { + code: 200, + msg: '操作成功', + data: { + current_model_id: 29, + exp_ins_id: 229, + version: 'v0.2.0', + ref_item: null, + train_task: { + name: '模型训练测试导出0529', + ins_id: 229, + task_id: 'model-train-5d76f002', + }, + train_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + train_params: ['256', '2'], + train_image: '172.20.32.187/machine-learning/pytorch:pytorch_1.9.1_cuda11.1_detection_aim', + test_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + project_dependency: { + url: 'https://openi.pcl.ac.cn/somunslotus/somun202304241505581.git', + name: 'somun202304241505581', + branch: 'train_ci_test', + }, + parent_models_map: [ + { + model_id: 29, + model_version: 'v0.1.0', + model_name: 'mnist模型演化', + }, + ], + parent_models: [ + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.1.0', + ref_item: null, + train_task: { + name: '模型训练测试导出0529', + ins_id: 229, + task_id: 'model-train-5d76f002', + }, + train_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + train_params: ['256', '2'], + train_image: + '172.20.32.187/machine-learning/pytorch:pytorch_1.9.1_cuda11.1_detection_aim', + test_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + project_dependency: { + url: 'https://openi.pcl.ac.cn/somunslotus/somun202304241505581.git', + name: 'somun202304241505581', + branch: 'train_ci_test', + }, + parent_models_map: [], + parent_models: [], + children_models: null, + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + ], + children_models: [ + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.3.0', + ref_item: null, + train_task: { + name: '模型训练测试导出0529', + ins_id: 229, + task_id: 'model-train-5d76f002', + }, + train_dataset: [ + { + dataset_id: 120, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + train_params: ['256', '2'], + train_image: + '172.20.32.187/machine-learning/pytorch:pytorch_1.9.1_cuda11.1_detection_aim', + test_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + project_dependency: { + url: 'https://openi.pcl.ac.cn/somunslotus/somun202304241505581.git', + name: 'somun202304241505581', + branch: 'train_ci_test', + }, + parent_models_map: [], + parent_models: [], + children_models: [], + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.31.0', + ref_item: null, + train_task: { + name: '模型训练测试导出0529', + ins_id: 229, + task_id: 'model-train-5d76f002', + }, + train_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + train_params: ['256', '2'], + train_image: + '172.20.32.187/machine-learning/pytorch:pytorch_1.9.1_cuda11.1_detection_aim', + test_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + project_dependency: { + url: 'https://openi.pcl.ac.cn/somunslotus/somun202304241505581.git', + name: 'somun202304241505581', + branch: 'train_ci_test', + }, + parent_models_map: [], + parent_models: [], + children_models: [], + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.4.0', + ref_item: null, + train_task: { + name: '模型训练测试导出0529', + ins_id: 229, + task_id: 'model-train-5d76f002', + }, + train_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + train_params: ['256', '2'], + train_image: + '172.20.32.187/machine-learning/pytorch:pytorch_1.9.1_cuda11.1_detection_aim', + test_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + project_dependency: { + url: 'https://openi.pcl.ac.cn/somunslotus/somun202304241505581.git', + name: 'somun202304241505581', + branch: 'train_ci_test', + }, + parent_models_map: [], + parent_models: [], + children_models: [ + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.6.0', + ref_item: null, + train_task: { + name: '模型训练测试导出0529', + ins_id: 229, + task_id: 'model-train-5d76f002', + }, + train_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + train_params: ['256', '2'], + train_image: + '172.20.32.187/machine-learning/pytorch:pytorch_1.9.1_cuda11.1_detection_aim', + test_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + project_dependency: { + url: 'https://openi.pcl.ac.cn/somunslotus/somun202304241505581.git', + name: 'somun202304241505581', + branch: 'train_ci_test', + }, + parent_models_map: [], + parent_models: [], + children_models: [], + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.7.0', + ref_item: null, + train_task: {}, + train_dataset: [], + train_params: [], + train_image: null, + test_dataset: [], + project_dependency: {}, + parent_models_map: [], + parent_models: [], + children_models: [], + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + ], + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.5.0', + ref_item: null, + train_task: { + name: '模型训练测试导出0529', + ins_id: 229, + task_id: 'model-train-5d76f002', + }, + train_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + train_params: ['256', '2'], + train_image: + '172.20.32.187/machine-learning/pytorch:pytorch_1.9.1_cuda11.1_detection_aim', + test_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + project_dependency: { + url: 'https://openi.pcl.ac.cn/somunslotus/somun202304241505581.git', + name: 'somun202304241505581', + branch: 'train_ci_test', + }, + parent_models_map: [], + parent_models: [], + children_models: [ + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.10.0', + ref_item: null, + train_task: {}, + train_dataset: [], + train_params: [], + train_image: null, + test_dataset: [], + project_dependency: {}, + parent_models_map: [], + parent_models: [], + children_models: [], + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + { + current_model_id: 29, + exp_ins_id: null, + version: 'v0.11.0', + ref_item: null, + train_task: { + name: '模型训练测试导出0529', + ins_id: 229, + task_id: 'model-train-5d76f002', + }, + train_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + train_params: ['256', '2'], + train_image: + '172.20.32.187/machine-learning/pytorch:pytorch_1.9.1_cuda11.1_detection_aim', + test_dataset: [ + { + dataset_id: 20, + dataset_version: 'v0.1.0', + dataset_name: '手写体识别模型依赖测试训练数据集', + }, + ], + project_dependency: { + url: 'https://openi.pcl.ac.cn/somunslotus/somun202304241505581.git', + name: 'somun202304241505581', + branch: 'train_ci_test', + }, + parent_models_map: [], + parent_models: [], + children_models: [], + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + ], + workflow_id: null, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172558449/mnist_epoch1_0.00.pkl', + file_name: 'mnist_epoch1_0.00.pkl', + file_size: '176.63 KB', + create_by: 'admin', + create_time: '2024-06-12T06:09:56.000+00:00', + }, + }, + ], + workflow_id: 144, + model_version_dependcy_vo: { + name: 'mnist模型演化', + description: '手写体识别模型演化', + available_range: 0, + model_type: '37', + model_tag: '46', + model_type_name: 'PyTorch', + model_tag_name: '图像转文本', + url: 'models/admin/1718172760650/mnist_cnn.pt', + file_name: 'mnist_cnn.pt', + file_size: '176.76 KB', + create_by: 'admin', + create_time: '2024-06-12T06:12:42.000+00:00', + }, + }, + }, +}); diff --git a/react-ui/mock/route.ts b/react-ui/mock/route.ts new file mode 100644 index 00000000..418d10f1 --- /dev/null +++ b/react-ui/mock/route.ts @@ -0,0 +1,5 @@ +export default { + '/api/auth_routes': { + '/form/advanced-form': { authority: ['admin', 'user'] }, + }, +}; diff --git a/react-ui/package.json b/react-ui/package.json new file mode 100644 index 00000000..4b28d7cf --- /dev/null +++ b/react-ui/package.json @@ -0,0 +1,184 @@ +{ + "name": "ci4s", + "version": "1.0.0", + "private": true, + "description": "", + "scripts": { + "analyze": "cross-env ANALYZE=1 max build", + "build": "max build", + "deploy": "npm run build && npm run gh-pages", + "dev": "npm run start:dev", + "dev-no-sso": "cross-env NO_SSO=true npm run start:mock", + "docker-hub:build": "docker build -f Dockerfile.hub -t ant-design-pro ./", + "docker-prod:build": "docker-compose -f ./docker/docker-compose.yml build", + "docker-prod:dev": "docker-compose -f ./docker/docker-compose.yml up", + "docker:build": "docker-compose -f ./docker/docker-compose.dev.yml build", + "docker:dev": "docker-compose -f ./docker/docker-compose.dev.yml up", + "docker:push": "npm run docker-hub:build && npm run docker:tag && docker push antdesign/ant-design-pro", + "docker:tag": "docker tag ant-design-pro antdesign/ant-design-pro", + "docs": "typedoc", + "gh-pages": "gh-pages -d dist", + "i18n-remove": "pro i18n-remove --locale=zh-CN --write", + "postinstall": "max setup", + "jest": "jest", + "lint": "npm run lint:js && npm run lint:prettier && npm run tsc", + "lint-staged": "lint-staged", + "lint-staged:js": "eslint --ext .js,.jsx,.ts,.tsx ", + "lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --format=pretty ./src ", + "lint:js": "eslint --cache --ext .js,.jsx,.ts,.tsx --format=pretty ./src", + "lint:prettier": "prettier -c --write \"**/**.{js,jsx,tsx,ts,less,md,json}\" --end-of-line auto", + "openapi": "max openapi", + "prepare": "cd .. && husky install", + "prettier": "prettier -c --write \"**/**.{js,jsx,tsx,ts,less,md,json}\"", + "preview": "npm run build && max preview --port 8000", + "record": "cross-env NODE_ENV=development REACT_APP_ENV=test max record --scene=login", + "serve": "umi-serve", + "start": "cross-env UMI_ENV=dev max dev", + "start:dev": "cross-env REACT_APP_ENV=dev MOCK=none UMI_ENV=dev UMI_DEV_SERVER_COMPRESS=none max dev", + "start:mock": "cross-env REACT_APP_ENV=dev UMI_ENV=dev UMI_DEV_SERVER_COMPRESS=none max dev", + "start:pre": "cross-env REACT_APP_ENV=pre UMI_ENV=dev max dev", + "start:test": "cross-env REACT_APP_ENV=test MOCK=none UMI_ENV=dev max dev", + "storybook": "storybook dev -p 6006", + "storybook-build": "storybook build", + "storybook-deploy": "./.storybook/scripts/upload-deploy.sh", + "storybook-docs": "storybook dev --docs", + "storybook-docs-build": "storybook build --docs", + "test": "jest", + "test:coverage": "npm run jest -- --coverage", + "test:update": "npm run jest -- -u", + "tsc": "tsc --noEmit" + }, + "lint-staged": { + "**/*.{js,jsx,ts,tsx}": "npm run lint-staged:js", + "**/*.{js,jsx,tsx,ts,less,md,json}": [ + "prettier --write" + ] + }, + "browserslist": [ + "> 1%", + "last 2 versions", + "not ie <= 10" + ], + "dependencies": { + "@ant-design/colors": "~7.2.1", + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-components": "^2.4.4", + "@ant-design/use-emotion-css": "1.0.4", + "@antv/g6": "^4.8.24", + "@antv/hierarchy": "^0.6.12", + "@ctrl/tinycolor": "~4.1.0", + "@types/crypto-js": "^4.2.2", + "@umijs/route-utils": "^4.0.1", + "antd": "~5.21.4", + "antd-style": "~3.7.1", + "caniuse-lite": "~1.0.30001707", + "classnames": "^2.3.2", + "crypto-js": "^4.2.0", + "echarts": "^5.5.0", + "fabric": "^5.3.0", + "highlight.js": "^11.7.0", + "lodash": "^4.17.21", + "motion": "~12.23.12", + "omit.js": "^2.0.2", + "pnpm": "^8.9.0", + "query-string": "^8.1.0", + "rc-menu": "^9.8.4", + "rc-util": "^5.30.0", + "react": "^18.2.0", + "react-activation": "^0.12.4", + "react-countup": "~6.5.3", + "react-cropper": "^2.3.3", + "react-dev-inspector": "^1.8.1", + "react-dom": "^18.2.0", + "react-draggable": "^4.4.6", + "react-helmet-async": "^1.3.0", + "react-highlight": "^0.15.0" + }, + "devDependencies": { + "@ant-design/pro-cli": "^3.1.0", + "@chromatic-com/storybook": "~3.2.4", + "@storybook/addon-essentials": "~8.5.3", + "@storybook/addon-interactions": "~8.5.3", + "@storybook/addon-onboarding": "~8.5.3", + "@storybook/addon-styling-webpack": "~1.0.1", + "@storybook/addon-webpack5-compiler-babel": "~3.0.5", + "@storybook/addon-webpack5-compiler-swc": "~2.0.0", + "@storybook/blocks": "~8.5.3", + "@storybook/manager-api": "~8.6.0", + "@storybook/react": "~8.5.3", + "@storybook/react-webpack5": "~8.5.3", + "@storybook/test": "~8.5.3", + "@storybook/theming": "~8.6.0", + "@testing-library/react": "^14.0.0", + "@types/antd": "^1.0.0", + "@types/express": "^4.17.14", + "@types/jest": "^29.5.1", + "@types/lodash": "^4.14.194", + "@types/react": "^18.0.38", + "@types/react-dom": "^18.0.11", + "@types/react-helmet": "^6.1.5", + "@types/react-highlight": "^0.12.5", + "@umijs/lint": "^4.0.66", + "@umijs/max": "^4.0.66", + "cross-env": "^7.0.3", + "eslint": "^8.39.0", + "eslint-plugin-react-hooks": "~5.2.0", + "eslint-plugin-storybook": "~0.11.2", + "express": "^4.18.2", + "gh-pages": "^5.0.0", + "husky": "^8.0.3", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "less": "~4.2.2", + "less-loader": "~12.2.0", + "lint-staged": "^13.2.0", + "mockjs": "^1.1.0", + "msw": "~2.7.0", + "msw-storybook-addon": "~2.0.4", + "prettier": "^2.8.1", + "storybook": "~8.5.3", + "swagger-ui-dist": "^4.18.2", + "ts-loader": "~9.5.2", + "ts-node": "^10.9.1", + "typedoc": "~0.28.1", + "typescript": "^5.0.4", + "umi-presets-pro": "^2.0.0" + }, + "engines": { + "node": ">=18.18.0" + }, + "create-umi": { + "ignoreScript": [ + "docker*", + "functions*", + "site", + "generateMock" + ], + "ignoreDependencies": [ + "netlify*", + "serverless" + ], + "ignore": [ + ".dockerignore", + ".git", + ".github", + ".gitpod.yml", + "CODE_OF_CONDUCT.md", + "Dockerfile", + "Dockerfile.*", + "lambda", + "LICENSE", + "netlify.toml", + "README.*.md", + "azure-pipelines.yml", + "docker", + "CNAME", + "create-umi" + ] + }, + "msw": { + "workerDirectory": [ + "public" + ] + } +} diff --git a/react-ui/public/assets/images/component-icon-1-Failed.png b/react-ui/public/assets/images/component-icon-1-Failed.png new file mode 100644 index 00000000..ca81ea52 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-1-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-1-Omitted.png b/react-ui/public/assets/images/component-icon-1-Omitted.png new file mode 100644 index 00000000..f4e53527 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-1-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-1-Pending.png b/react-ui/public/assets/images/component-icon-1-Pending.png new file mode 100644 index 00000000..7f4c9eca Binary files /dev/null and b/react-ui/public/assets/images/component-icon-1-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-1-Running.png b/react-ui/public/assets/images/component-icon-1-Running.png new file mode 100644 index 00000000..06562f00 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-1-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-1-Skipped.png b/react-ui/public/assets/images/component-icon-1-Skipped.png new file mode 100644 index 00000000..f4e53527 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-1-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-1-Succeeded.png b/react-ui/public/assets/images/component-icon-1-Succeeded.png new file mode 100644 index 00000000..9b74bfc7 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-1-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-1.png b/react-ui/public/assets/images/component-icon-1.png new file mode 100644 index 00000000..4568e3b2 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-1.png differ diff --git a/react-ui/public/assets/images/component-icon-2-Failed.png b/react-ui/public/assets/images/component-icon-2-Failed.png new file mode 100644 index 00000000..e9808639 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-2-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-2-Omitted.png b/react-ui/public/assets/images/component-icon-2-Omitted.png new file mode 100644 index 00000000..6ee447f3 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-2-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-2-Pending.png b/react-ui/public/assets/images/component-icon-2-Pending.png new file mode 100644 index 00000000..20c62415 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-2-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-2-Running.png b/react-ui/public/assets/images/component-icon-2-Running.png new file mode 100644 index 00000000..14d102cd Binary files /dev/null and b/react-ui/public/assets/images/component-icon-2-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-2-Skipped.png b/react-ui/public/assets/images/component-icon-2-Skipped.png new file mode 100644 index 00000000..6ee447f3 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-2-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-2-Succeeded.png b/react-ui/public/assets/images/component-icon-2-Succeeded.png new file mode 100644 index 00000000..4cb50702 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-2-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-2.png b/react-ui/public/assets/images/component-icon-2.png new file mode 100644 index 00000000..634a8762 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-2.png differ diff --git a/react-ui/public/assets/images/component-icon-3-Failed.png b/react-ui/public/assets/images/component-icon-3-Failed.png new file mode 100644 index 00000000..214db255 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-3-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-3-Omitted.png b/react-ui/public/assets/images/component-icon-3-Omitted.png new file mode 100644 index 00000000..d2eb16ac Binary files /dev/null and b/react-ui/public/assets/images/component-icon-3-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-3-Pending.png b/react-ui/public/assets/images/component-icon-3-Pending.png new file mode 100644 index 00000000..54425b16 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-3-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-3-Running.png b/react-ui/public/assets/images/component-icon-3-Running.png new file mode 100644 index 00000000..346e566c Binary files /dev/null and b/react-ui/public/assets/images/component-icon-3-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-3-Skipped.png b/react-ui/public/assets/images/component-icon-3-Skipped.png new file mode 100644 index 00000000..d2eb16ac Binary files /dev/null and b/react-ui/public/assets/images/component-icon-3-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-3-Succeeded.png b/react-ui/public/assets/images/component-icon-3-Succeeded.png new file mode 100644 index 00000000..78078b15 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-3-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-3.png b/react-ui/public/assets/images/component-icon-3.png new file mode 100644 index 00000000..d90937d0 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-3.png differ diff --git a/react-ui/public/assets/images/component-icon-4-Failed.png b/react-ui/public/assets/images/component-icon-4-Failed.png new file mode 100644 index 00000000..319b90d2 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-4-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-4-Omitted.png b/react-ui/public/assets/images/component-icon-4-Omitted.png new file mode 100644 index 00000000..3d98c224 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-4-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-4-Pending.png b/react-ui/public/assets/images/component-icon-4-Pending.png new file mode 100644 index 00000000..9bcfab74 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-4-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-4-Running.png b/react-ui/public/assets/images/component-icon-4-Running.png new file mode 100644 index 00000000..079b1809 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-4-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-4-Skipped.png b/react-ui/public/assets/images/component-icon-4-Skipped.png new file mode 100644 index 00000000..3d98c224 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-4-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-4-Succeeded.png b/react-ui/public/assets/images/component-icon-4-Succeeded.png new file mode 100644 index 00000000..2263b17b Binary files /dev/null and b/react-ui/public/assets/images/component-icon-4-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-4.png b/react-ui/public/assets/images/component-icon-4.png new file mode 100644 index 00000000..2858dc19 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-4.png differ diff --git a/react-ui/public/assets/images/component-icon-5-Failed.png b/react-ui/public/assets/images/component-icon-5-Failed.png new file mode 100644 index 00000000..5d38372c Binary files /dev/null and b/react-ui/public/assets/images/component-icon-5-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-5-Omitted.png b/react-ui/public/assets/images/component-icon-5-Omitted.png new file mode 100644 index 00000000..4685f2fd Binary files /dev/null and b/react-ui/public/assets/images/component-icon-5-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-5-Pending.png b/react-ui/public/assets/images/component-icon-5-Pending.png new file mode 100644 index 00000000..65815355 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-5-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-5-Running.png b/react-ui/public/assets/images/component-icon-5-Running.png new file mode 100644 index 00000000..37a609a8 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-5-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-5-Skipped.png b/react-ui/public/assets/images/component-icon-5-Skipped.png new file mode 100644 index 00000000..4685f2fd Binary files /dev/null and b/react-ui/public/assets/images/component-icon-5-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-5-Succeeded.png b/react-ui/public/assets/images/component-icon-5-Succeeded.png new file mode 100644 index 00000000..c96e1748 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-5-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-5.png b/react-ui/public/assets/images/component-icon-5.png new file mode 100644 index 00000000..2a56ee76 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-5.png differ diff --git a/react-ui/public/assets/images/component-icon-6-Failed.png b/react-ui/public/assets/images/component-icon-6-Failed.png new file mode 100644 index 00000000..8ad38ef0 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-6-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-6-Omitted.png b/react-ui/public/assets/images/component-icon-6-Omitted.png new file mode 100644 index 00000000..8999b577 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-6-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-6-Pending.png b/react-ui/public/assets/images/component-icon-6-Pending.png new file mode 100644 index 00000000..8c8c1639 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-6-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-6-Running.png b/react-ui/public/assets/images/component-icon-6-Running.png new file mode 100644 index 00000000..012f05ca Binary files /dev/null and b/react-ui/public/assets/images/component-icon-6-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-6-Skipped.png b/react-ui/public/assets/images/component-icon-6-Skipped.png new file mode 100644 index 00000000..8999b577 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-6-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-6-Succeeded.png b/react-ui/public/assets/images/component-icon-6-Succeeded.png new file mode 100644 index 00000000..c388f7db Binary files /dev/null and b/react-ui/public/assets/images/component-icon-6-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-6.png b/react-ui/public/assets/images/component-icon-6.png new file mode 100644 index 00000000..6bfbbf6a Binary files /dev/null and b/react-ui/public/assets/images/component-icon-6.png differ diff --git a/react-ui/public/assets/images/component-icon-7-Failed.png b/react-ui/public/assets/images/component-icon-7-Failed.png new file mode 100644 index 00000000..9854dc38 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-7-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-7-Omitted.png b/react-ui/public/assets/images/component-icon-7-Omitted.png new file mode 100644 index 00000000..59b81026 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-7-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-7-Pending.png b/react-ui/public/assets/images/component-icon-7-Pending.png new file mode 100644 index 00000000..bf06cf26 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-7-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-7-Running.png b/react-ui/public/assets/images/component-icon-7-Running.png new file mode 100644 index 00000000..6dcc20d6 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-7-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-7-Skipped.png b/react-ui/public/assets/images/component-icon-7-Skipped.png new file mode 100644 index 00000000..59b81026 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-7-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-7-Succeeded.png b/react-ui/public/assets/images/component-icon-7-Succeeded.png new file mode 100644 index 00000000..f12a5a82 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-7-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-7.png b/react-ui/public/assets/images/component-icon-7.png new file mode 100644 index 00000000..d550d28c Binary files /dev/null and b/react-ui/public/assets/images/component-icon-7.png differ diff --git a/react-ui/public/assets/images/component-icon-8-Failed.png b/react-ui/public/assets/images/component-icon-8-Failed.png new file mode 100644 index 00000000..a5d20128 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-8-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-8-Omitted.png b/react-ui/public/assets/images/component-icon-8-Omitted.png new file mode 100644 index 00000000..ab0015cc Binary files /dev/null and b/react-ui/public/assets/images/component-icon-8-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-8-Pending.png b/react-ui/public/assets/images/component-icon-8-Pending.png new file mode 100644 index 00000000..3dfc540e Binary files /dev/null and b/react-ui/public/assets/images/component-icon-8-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-8-Running.png b/react-ui/public/assets/images/component-icon-8-Running.png new file mode 100644 index 00000000..4713143e Binary files /dev/null and b/react-ui/public/assets/images/component-icon-8-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-8-Skipped.png b/react-ui/public/assets/images/component-icon-8-Skipped.png new file mode 100644 index 00000000..ab0015cc Binary files /dev/null and b/react-ui/public/assets/images/component-icon-8-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-8-Succeeded.png b/react-ui/public/assets/images/component-icon-8-Succeeded.png new file mode 100644 index 00000000..c890e8a1 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-8-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-8.png b/react-ui/public/assets/images/component-icon-8.png new file mode 100644 index 00000000..81dfbe9e Binary files /dev/null and b/react-ui/public/assets/images/component-icon-8.png differ diff --git a/react-ui/public/assets/images/component-icon-9-Failed.png b/react-ui/public/assets/images/component-icon-9-Failed.png new file mode 100644 index 00000000..64fae87a Binary files /dev/null and b/react-ui/public/assets/images/component-icon-9-Failed.png differ diff --git a/react-ui/public/assets/images/component-icon-9-Omitted.png b/react-ui/public/assets/images/component-icon-9-Omitted.png new file mode 100644 index 00000000..2b2911c6 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-9-Omitted.png differ diff --git a/react-ui/public/assets/images/component-icon-9-Pending.png b/react-ui/public/assets/images/component-icon-9-Pending.png new file mode 100644 index 00000000..a684bdc0 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-9-Pending.png differ diff --git a/react-ui/public/assets/images/component-icon-9-Running.png b/react-ui/public/assets/images/component-icon-9-Running.png new file mode 100644 index 00000000..b96deabe Binary files /dev/null and b/react-ui/public/assets/images/component-icon-9-Running.png differ diff --git a/react-ui/public/assets/images/component-icon-9-Skipped.png b/react-ui/public/assets/images/component-icon-9-Skipped.png new file mode 100644 index 00000000..2b2911c6 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-9-Skipped.png differ diff --git a/react-ui/public/assets/images/component-icon-9-Succeeded.png b/react-ui/public/assets/images/component-icon-9-Succeeded.png new file mode 100644 index 00000000..f18af9c9 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-9-Succeeded.png differ diff --git a/react-ui/public/assets/images/component-icon-9.png b/react-ui/public/assets/images/component-icon-9.png new file mode 100644 index 00000000..92dae064 Binary files /dev/null and b/react-ui/public/assets/images/component-icon-9.png differ diff --git a/react-ui/public/assets/images/dataset/101-hover.png b/react-ui/public/assets/images/dataset/101-hover.png new file mode 100644 index 00000000..cb72d1cd Binary files /dev/null and b/react-ui/public/assets/images/dataset/101-hover.png differ diff --git a/react-ui/public/assets/images/dataset/101.png b/react-ui/public/assets/images/dataset/101.png new file mode 100644 index 00000000..d1e93c0e Binary files /dev/null and b/react-ui/public/assets/images/dataset/101.png differ diff --git a/react-ui/public/assets/images/dataset/102-hover.png b/react-ui/public/assets/images/dataset/102-hover.png new file mode 100644 index 00000000..2a7d8940 Binary files /dev/null and b/react-ui/public/assets/images/dataset/102-hover.png differ diff --git a/react-ui/public/assets/images/dataset/102.png b/react-ui/public/assets/images/dataset/102.png new file mode 100644 index 00000000..13b6f312 Binary files /dev/null and b/react-ui/public/assets/images/dataset/102.png differ diff --git a/react-ui/public/assets/images/dataset/103-hover.png b/react-ui/public/assets/images/dataset/103-hover.png new file mode 100644 index 00000000..223f57fd Binary files /dev/null and b/react-ui/public/assets/images/dataset/103-hover.png differ diff --git a/react-ui/public/assets/images/dataset/103.png b/react-ui/public/assets/images/dataset/103.png new file mode 100644 index 00000000..654e3f00 Binary files /dev/null and b/react-ui/public/assets/images/dataset/103.png differ diff --git a/react-ui/public/assets/images/dataset/104-hover.png b/react-ui/public/assets/images/dataset/104-hover.png new file mode 100644 index 00000000..1132b52d Binary files /dev/null and b/react-ui/public/assets/images/dataset/104-hover.png differ diff --git a/react-ui/public/assets/images/dataset/104.png b/react-ui/public/assets/images/dataset/104.png new file mode 100644 index 00000000..49efaf49 Binary files /dev/null and b/react-ui/public/assets/images/dataset/104.png differ diff --git a/react-ui/public/assets/images/dataset/105-hover.png b/react-ui/public/assets/images/dataset/105-hover.png new file mode 100644 index 00000000..3b671ec9 Binary files /dev/null and b/react-ui/public/assets/images/dataset/105-hover.png differ diff --git a/react-ui/public/assets/images/dataset/105.png b/react-ui/public/assets/images/dataset/105.png new file mode 100644 index 00000000..e46f0703 Binary files /dev/null and b/react-ui/public/assets/images/dataset/105.png differ diff --git a/react-ui/public/assets/images/dataset/201-hover.png b/react-ui/public/assets/images/dataset/201-hover.png new file mode 100644 index 00000000..ce75d970 Binary files /dev/null and b/react-ui/public/assets/images/dataset/201-hover.png differ diff --git a/react-ui/public/assets/images/dataset/201.png b/react-ui/public/assets/images/dataset/201.png new file mode 100644 index 00000000..9c250f5e Binary files /dev/null and b/react-ui/public/assets/images/dataset/201.png differ diff --git a/react-ui/public/assets/images/dataset/202-hover.png b/react-ui/public/assets/images/dataset/202-hover.png new file mode 100644 index 00000000..473fa1ee Binary files /dev/null and b/react-ui/public/assets/images/dataset/202-hover.png differ diff --git a/react-ui/public/assets/images/dataset/202.png b/react-ui/public/assets/images/dataset/202.png new file mode 100644 index 00000000..10bb9a70 Binary files /dev/null and b/react-ui/public/assets/images/dataset/202.png differ diff --git a/react-ui/public/assets/images/dataset/203-hover.png b/react-ui/public/assets/images/dataset/203-hover.png new file mode 100644 index 00000000..67f9be74 Binary files /dev/null and b/react-ui/public/assets/images/dataset/203-hover.png differ diff --git a/react-ui/public/assets/images/dataset/203.png b/react-ui/public/assets/images/dataset/203.png new file mode 100644 index 00000000..7cd68e50 Binary files /dev/null and b/react-ui/public/assets/images/dataset/203.png differ diff --git a/react-ui/public/assets/images/dataset/204-hover.png b/react-ui/public/assets/images/dataset/204-hover.png new file mode 100644 index 00000000..add14532 Binary files /dev/null and b/react-ui/public/assets/images/dataset/204-hover.png differ diff --git a/react-ui/public/assets/images/dataset/204.png b/react-ui/public/assets/images/dataset/204.png new file mode 100644 index 00000000..f1b1c7f4 Binary files /dev/null and b/react-ui/public/assets/images/dataset/204.png differ diff --git a/react-ui/public/assets/images/dataset/205-hover.png b/react-ui/public/assets/images/dataset/205-hover.png new file mode 100644 index 00000000..1335a72c Binary files /dev/null and b/react-ui/public/assets/images/dataset/205-hover.png differ diff --git a/react-ui/public/assets/images/dataset/205.png b/react-ui/public/assets/images/dataset/205.png new file mode 100644 index 00000000..e4b57231 Binary files /dev/null and b/react-ui/public/assets/images/dataset/205.png differ diff --git a/react-ui/public/assets/images/dataset/206-hover.png b/react-ui/public/assets/images/dataset/206-hover.png new file mode 100644 index 00000000..a2d94e8f Binary files /dev/null and b/react-ui/public/assets/images/dataset/206-hover.png differ diff --git a/react-ui/public/assets/images/dataset/206.png b/react-ui/public/assets/images/dataset/206.png new file mode 100644 index 00000000..dadaed7c Binary files /dev/null and b/react-ui/public/assets/images/dataset/206.png differ diff --git a/react-ui/public/assets/images/dataset/207-hover.png b/react-ui/public/assets/images/dataset/207-hover.png new file mode 100644 index 00000000..332e3d2f Binary files /dev/null and b/react-ui/public/assets/images/dataset/207-hover.png differ diff --git a/react-ui/public/assets/images/dataset/207.png b/react-ui/public/assets/images/dataset/207.png new file mode 100644 index 00000000..19c5a5ae Binary files /dev/null and b/react-ui/public/assets/images/dataset/207.png differ diff --git a/react-ui/public/assets/images/dataset/208-hover.png b/react-ui/public/assets/images/dataset/208-hover.png new file mode 100644 index 00000000..f301b63b Binary files /dev/null and b/react-ui/public/assets/images/dataset/208-hover.png differ diff --git a/react-ui/public/assets/images/dataset/208.png b/react-ui/public/assets/images/dataset/208.png new file mode 100644 index 00000000..5ac7b1d6 Binary files /dev/null and b/react-ui/public/assets/images/dataset/208.png differ diff --git a/react-ui/public/assets/images/dataset/209-hover.png b/react-ui/public/assets/images/dataset/209-hover.png new file mode 100644 index 00000000..b35d2d69 Binary files /dev/null and b/react-ui/public/assets/images/dataset/209-hover.png differ diff --git a/react-ui/public/assets/images/dataset/209.png b/react-ui/public/assets/images/dataset/209.png new file mode 100644 index 00000000..98f224d3 Binary files /dev/null and b/react-ui/public/assets/images/dataset/209.png differ diff --git a/react-ui/public/assets/images/dataset/210-hover.png b/react-ui/public/assets/images/dataset/210-hover.png new file mode 100644 index 00000000..ad753acf Binary files /dev/null and b/react-ui/public/assets/images/dataset/210-hover.png differ diff --git a/react-ui/public/assets/images/dataset/210.png b/react-ui/public/assets/images/dataset/210.png new file mode 100644 index 00000000..f96c9bf9 Binary files /dev/null and b/react-ui/public/assets/images/dataset/210.png differ diff --git a/react-ui/public/assets/images/dataset/211-hover.png b/react-ui/public/assets/images/dataset/211-hover.png new file mode 100644 index 00000000..c46621ac Binary files /dev/null and b/react-ui/public/assets/images/dataset/211-hover.png differ diff --git a/react-ui/public/assets/images/dataset/211.png b/react-ui/public/assets/images/dataset/211.png new file mode 100644 index 00000000..f1b2860a Binary files /dev/null and b/react-ui/public/assets/images/dataset/211.png differ diff --git a/react-ui/public/assets/images/dataset/2115-hover.png b/react-ui/public/assets/images/dataset/2115-hover.png new file mode 100644 index 00000000..b3bb5fa7 Binary files /dev/null and b/react-ui/public/assets/images/dataset/2115-hover.png differ diff --git a/react-ui/public/assets/images/dataset/212-hover.png b/react-ui/public/assets/images/dataset/212-hover.png new file mode 100644 index 00000000..3634e695 Binary files /dev/null and b/react-ui/public/assets/images/dataset/212-hover.png differ diff --git a/react-ui/public/assets/images/dataset/212.png b/react-ui/public/assets/images/dataset/212.png new file mode 100644 index 00000000..358d1402 Binary files /dev/null and b/react-ui/public/assets/images/dataset/212.png differ diff --git a/react-ui/public/assets/images/dataset/213-hover.png b/react-ui/public/assets/images/dataset/213-hover.png new file mode 100644 index 00000000..267126e2 Binary files /dev/null and b/react-ui/public/assets/images/dataset/213-hover.png differ diff --git a/react-ui/public/assets/images/dataset/213.png b/react-ui/public/assets/images/dataset/213.png new file mode 100644 index 00000000..b08dddf3 Binary files /dev/null and b/react-ui/public/assets/images/dataset/213.png differ diff --git a/react-ui/public/assets/images/dataset/214-hover.png b/react-ui/public/assets/images/dataset/214-hover.png new file mode 100644 index 00000000..77451aba Binary files /dev/null and b/react-ui/public/assets/images/dataset/214-hover.png differ diff --git a/react-ui/public/assets/images/dataset/214.png b/react-ui/public/assets/images/dataset/214.png new file mode 100644 index 00000000..62107d2a Binary files /dev/null and b/react-ui/public/assets/images/dataset/214.png differ diff --git a/react-ui/public/assets/images/dataset/215-hover.png b/react-ui/public/assets/images/dataset/215-hover.png new file mode 100644 index 00000000..b3bb5fa7 Binary files /dev/null and b/react-ui/public/assets/images/dataset/215-hover.png differ diff --git a/react-ui/public/assets/images/dataset/215.png b/react-ui/public/assets/images/dataset/215.png new file mode 100644 index 00000000..7c38ceca Binary files /dev/null and b/react-ui/public/assets/images/dataset/215.png differ diff --git a/react-ui/public/assets/images/dataset/216-hover.png b/react-ui/public/assets/images/dataset/216-hover.png new file mode 100644 index 00000000..1b6c5bd1 Binary files /dev/null and b/react-ui/public/assets/images/dataset/216-hover.png differ diff --git a/react-ui/public/assets/images/dataset/216.png b/react-ui/public/assets/images/dataset/216.png new file mode 100644 index 00000000..0a64c541 Binary files /dev/null and b/react-ui/public/assets/images/dataset/216.png differ diff --git a/react-ui/public/assets/images/dataset/217-hover.png b/react-ui/public/assets/images/dataset/217-hover.png new file mode 100644 index 00000000..25da4dac Binary files /dev/null and b/react-ui/public/assets/images/dataset/217-hover.png differ diff --git a/react-ui/public/assets/images/dataset/217.png b/react-ui/public/assets/images/dataset/217.png new file mode 100644 index 00000000..13e827b8 Binary files /dev/null and b/react-ui/public/assets/images/dataset/217.png differ diff --git a/react-ui/public/assets/images/dataset/218-hover.png b/react-ui/public/assets/images/dataset/218-hover.png new file mode 100644 index 00000000..1ed77e55 Binary files /dev/null and b/react-ui/public/assets/images/dataset/218-hover.png differ diff --git a/react-ui/public/assets/images/dataset/218.png b/react-ui/public/assets/images/dataset/218.png new file mode 100644 index 00000000..1dc451c5 Binary files /dev/null and b/react-ui/public/assets/images/dataset/218.png differ diff --git a/react-ui/public/assets/images/dataset/219-hover.png b/react-ui/public/assets/images/dataset/219-hover.png new file mode 100644 index 00000000..5f241bed Binary files /dev/null and b/react-ui/public/assets/images/dataset/219-hover.png differ diff --git a/react-ui/public/assets/images/dataset/219.png b/react-ui/public/assets/images/dataset/219.png new file mode 100644 index 00000000..ee13574d Binary files /dev/null and b/react-ui/public/assets/images/dataset/219.png differ diff --git a/react-ui/public/assets/images/dataset/220-hover.png b/react-ui/public/assets/images/dataset/220-hover.png new file mode 100644 index 00000000..510a48aa Binary files /dev/null and b/react-ui/public/assets/images/dataset/220-hover.png differ diff --git a/react-ui/public/assets/images/dataset/220.png b/react-ui/public/assets/images/dataset/220.png new file mode 100644 index 00000000..fd967601 Binary files /dev/null and b/react-ui/public/assets/images/dataset/220.png differ diff --git a/react-ui/public/assets/images/dataset/221-hover.png b/react-ui/public/assets/images/dataset/221-hover.png new file mode 100644 index 00000000..5d63f41c Binary files /dev/null and b/react-ui/public/assets/images/dataset/221-hover.png differ diff --git a/react-ui/public/assets/images/dataset/221.png b/react-ui/public/assets/images/dataset/221.png new file mode 100644 index 00000000..4a17dfc0 Binary files /dev/null and b/react-ui/public/assets/images/dataset/221.png differ diff --git a/react-ui/public/assets/images/dataset/222-hover.png b/react-ui/public/assets/images/dataset/222-hover.png new file mode 100644 index 00000000..e9a3f6b9 Binary files /dev/null and b/react-ui/public/assets/images/dataset/222-hover.png differ diff --git a/react-ui/public/assets/images/dataset/222.png b/react-ui/public/assets/images/dataset/222.png new file mode 100644 index 00000000..dd6fbfbe Binary files /dev/null and b/react-ui/public/assets/images/dataset/222.png differ diff --git a/react-ui/public/assets/images/dataset/223-hover.png b/react-ui/public/assets/images/dataset/223-hover.png new file mode 100644 index 00000000..dff4b183 Binary files /dev/null and b/react-ui/public/assets/images/dataset/223-hover.png differ diff --git a/react-ui/public/assets/images/dataset/223.png b/react-ui/public/assets/images/dataset/223.png new file mode 100644 index 00000000..8a83957e Binary files /dev/null and b/react-ui/public/assets/images/dataset/223.png differ diff --git a/react-ui/public/assets/images/dataset/224-hover.png b/react-ui/public/assets/images/dataset/224-hover.png new file mode 100644 index 00000000..18641ec3 Binary files /dev/null and b/react-ui/public/assets/images/dataset/224-hover.png differ diff --git a/react-ui/public/assets/images/dataset/224.png b/react-ui/public/assets/images/dataset/224.png new file mode 100644 index 00000000..6adb7430 Binary files /dev/null and b/react-ui/public/assets/images/dataset/224.png differ diff --git a/react-ui/public/assets/images/dataset/225-hover.png b/react-ui/public/assets/images/dataset/225-hover.png new file mode 100644 index 00000000..13f0ff47 Binary files /dev/null and b/react-ui/public/assets/images/dataset/225-hover.png differ diff --git a/react-ui/public/assets/images/dataset/225.png b/react-ui/public/assets/images/dataset/225.png new file mode 100644 index 00000000..a16ca3e9 Binary files /dev/null and b/react-ui/public/assets/images/dataset/225.png differ diff --git a/react-ui/public/assets/images/dataset/226-hover.png b/react-ui/public/assets/images/dataset/226-hover.png new file mode 100644 index 00000000..f1189c3a Binary files /dev/null and b/react-ui/public/assets/images/dataset/226-hover.png differ diff --git a/react-ui/public/assets/images/dataset/226.png b/react-ui/public/assets/images/dataset/226.png new file mode 100644 index 00000000..0c26129c Binary files /dev/null and b/react-ui/public/assets/images/dataset/226.png differ diff --git a/react-ui/public/assets/images/dataset/227-hover.png b/react-ui/public/assets/images/dataset/227-hover.png new file mode 100644 index 00000000..c7dfb095 Binary files /dev/null and b/react-ui/public/assets/images/dataset/227-hover.png differ diff --git a/react-ui/public/assets/images/dataset/227.png b/react-ui/public/assets/images/dataset/227.png new file mode 100644 index 00000000..fcfb2b32 Binary files /dev/null and b/react-ui/public/assets/images/dataset/227.png differ diff --git a/react-ui/public/assets/images/dataset/228-hover.png b/react-ui/public/assets/images/dataset/228-hover.png new file mode 100644 index 00000000..ad4dcfea Binary files /dev/null and b/react-ui/public/assets/images/dataset/228-hover.png differ diff --git a/react-ui/public/assets/images/dataset/228.png b/react-ui/public/assets/images/dataset/228.png new file mode 100644 index 00000000..ff0dd839 Binary files /dev/null and b/react-ui/public/assets/images/dataset/228.png differ diff --git a/react-ui/public/assets/images/dataset/229-hover.png b/react-ui/public/assets/images/dataset/229-hover.png new file mode 100644 index 00000000..da3eb819 Binary files /dev/null and b/react-ui/public/assets/images/dataset/229-hover.png differ diff --git a/react-ui/public/assets/images/dataset/229.png b/react-ui/public/assets/images/dataset/229.png new file mode 100644 index 00000000..a9944b63 Binary files /dev/null and b/react-ui/public/assets/images/dataset/229.png differ diff --git a/react-ui/public/assets/images/dataset/230-hover.png b/react-ui/public/assets/images/dataset/230-hover.png new file mode 100644 index 00000000..b5ee43a2 Binary files /dev/null and b/react-ui/public/assets/images/dataset/230-hover.png differ diff --git a/react-ui/public/assets/images/dataset/230.png b/react-ui/public/assets/images/dataset/230.png new file mode 100644 index 00000000..19615631 Binary files /dev/null and b/react-ui/public/assets/images/dataset/230.png differ diff --git a/react-ui/public/assets/images/dataset/231-hover.png b/react-ui/public/assets/images/dataset/231-hover.png new file mode 100644 index 00000000..21b5be81 Binary files /dev/null and b/react-ui/public/assets/images/dataset/231-hover.png differ diff --git a/react-ui/public/assets/images/dataset/231.png b/react-ui/public/assets/images/dataset/231.png new file mode 100644 index 00000000..f338c66d Binary files /dev/null and b/react-ui/public/assets/images/dataset/231.png differ diff --git a/react-ui/public/assets/images/experiment-status/fail-icon.png b/react-ui/public/assets/images/experiment-status/fail-icon.png new file mode 100644 index 00000000..da6c16cd Binary files /dev/null and b/react-ui/public/assets/images/experiment-status/fail-icon.png differ diff --git a/react-ui/public/assets/images/experiment-status/omitted-icon.png b/react-ui/public/assets/images/experiment-status/omitted-icon.png new file mode 100644 index 00000000..1c1f2e35 Binary files /dev/null and b/react-ui/public/assets/images/experiment-status/omitted-icon.png differ diff --git a/react-ui/public/assets/images/experiment-status/pending-icon.png b/react-ui/public/assets/images/experiment-status/pending-icon.png new file mode 100644 index 00000000..7f5ea74f Binary files /dev/null and b/react-ui/public/assets/images/experiment-status/pending-icon.png differ diff --git a/react-ui/public/assets/images/experiment-status/running-icon.png b/react-ui/public/assets/images/experiment-status/running-icon.png new file mode 100644 index 00000000..6182ada1 Binary files /dev/null and b/react-ui/public/assets/images/experiment-status/running-icon.png differ diff --git a/react-ui/public/assets/images/experiment-status/success-icon.png b/react-ui/public/assets/images/experiment-status/success-icon.png new file mode 100644 index 00000000..83ff96b7 Binary files /dev/null and b/react-ui/public/assets/images/experiment-status/success-icon.png differ diff --git a/react-ui/public/assets/images/model/301-hover.png b/react-ui/public/assets/images/model/301-hover.png new file mode 100644 index 00000000..7e5ec51c Binary files /dev/null and b/react-ui/public/assets/images/model/301-hover.png differ diff --git a/react-ui/public/assets/images/model/301.png b/react-ui/public/assets/images/model/301.png new file mode 100644 index 00000000..25657ea3 Binary files /dev/null and b/react-ui/public/assets/images/model/301.png differ diff --git a/react-ui/public/assets/images/model/302-hover.png b/react-ui/public/assets/images/model/302-hover.png new file mode 100644 index 00000000..ba5204b2 Binary files /dev/null and b/react-ui/public/assets/images/model/302-hover.png differ diff --git a/react-ui/public/assets/images/model/302.png b/react-ui/public/assets/images/model/302.png new file mode 100644 index 00000000..dbc659df Binary files /dev/null and b/react-ui/public/assets/images/model/302.png differ diff --git a/react-ui/public/assets/images/model/303-hover.png b/react-ui/public/assets/images/model/303-hover.png new file mode 100644 index 00000000..e8397758 Binary files /dev/null and b/react-ui/public/assets/images/model/303-hover.png differ diff --git a/react-ui/public/assets/images/model/303.png b/react-ui/public/assets/images/model/303.png new file mode 100644 index 00000000..a5cef19a Binary files /dev/null and b/react-ui/public/assets/images/model/303.png differ diff --git a/react-ui/public/assets/images/model/304-hover.png b/react-ui/public/assets/images/model/304-hover.png new file mode 100644 index 00000000..a33f5bbe Binary files /dev/null and b/react-ui/public/assets/images/model/304-hover.png differ diff --git a/react-ui/public/assets/images/model/304.png b/react-ui/public/assets/images/model/304.png new file mode 100644 index 00000000..ac04f077 Binary files /dev/null and b/react-ui/public/assets/images/model/304.png differ diff --git a/react-ui/public/assets/images/model/305-hover.png b/react-ui/public/assets/images/model/305-hover.png new file mode 100644 index 00000000..a2b6d11c Binary files /dev/null and b/react-ui/public/assets/images/model/305-hover.png differ diff --git a/react-ui/public/assets/images/model/305.png b/react-ui/public/assets/images/model/305.png new file mode 100644 index 00000000..d325e9b5 Binary files /dev/null and b/react-ui/public/assets/images/model/305.png differ diff --git a/react-ui/public/assets/images/model/306-hover.png b/react-ui/public/assets/images/model/306-hover.png new file mode 100644 index 00000000..40bdb681 Binary files /dev/null and b/react-ui/public/assets/images/model/306-hover.png differ diff --git a/react-ui/public/assets/images/model/306.png b/react-ui/public/assets/images/model/306.png new file mode 100644 index 00000000..93294f06 Binary files /dev/null and b/react-ui/public/assets/images/model/306.png differ diff --git a/react-ui/public/assets/images/model/307-hover.png b/react-ui/public/assets/images/model/307-hover.png new file mode 100644 index 00000000..fd2cfb13 Binary files /dev/null and b/react-ui/public/assets/images/model/307-hover.png differ diff --git a/react-ui/public/assets/images/model/307.png b/react-ui/public/assets/images/model/307.png new file mode 100644 index 00000000..51ebc12a Binary files /dev/null and b/react-ui/public/assets/images/model/307.png differ diff --git a/react-ui/public/assets/images/model/401-hover.png b/react-ui/public/assets/images/model/401-hover.png new file mode 100644 index 00000000..5ba8329e Binary files /dev/null and b/react-ui/public/assets/images/model/401-hover.png differ diff --git a/react-ui/public/assets/images/model/401.png b/react-ui/public/assets/images/model/401.png new file mode 100644 index 00000000..b956b157 Binary files /dev/null and b/react-ui/public/assets/images/model/401.png differ diff --git a/react-ui/public/assets/images/model/402-hover.png b/react-ui/public/assets/images/model/402-hover.png new file mode 100644 index 00000000..7c7a1556 Binary files /dev/null and b/react-ui/public/assets/images/model/402-hover.png differ diff --git a/react-ui/public/assets/images/model/402.png b/react-ui/public/assets/images/model/402.png new file mode 100644 index 00000000..324e16fc Binary files /dev/null and b/react-ui/public/assets/images/model/402.png differ diff --git a/react-ui/public/assets/images/model/403-hover.png b/react-ui/public/assets/images/model/403-hover.png new file mode 100644 index 00000000..525751e0 Binary files /dev/null and b/react-ui/public/assets/images/model/403-hover.png differ diff --git a/react-ui/public/assets/images/model/403.png b/react-ui/public/assets/images/model/403.png new file mode 100644 index 00000000..915a6b7f Binary files /dev/null and b/react-ui/public/assets/images/model/403.png differ diff --git a/react-ui/public/assets/images/model/404-hover.png b/react-ui/public/assets/images/model/404-hover.png new file mode 100644 index 00000000..f953e03a Binary files /dev/null and b/react-ui/public/assets/images/model/404-hover.png differ diff --git a/react-ui/public/assets/images/model/404.png b/react-ui/public/assets/images/model/404.png new file mode 100644 index 00000000..52769661 Binary files /dev/null and b/react-ui/public/assets/images/model/404.png differ diff --git a/react-ui/public/assets/images/model/405-hover.png b/react-ui/public/assets/images/model/405-hover.png new file mode 100644 index 00000000..473fa1ee Binary files /dev/null and b/react-ui/public/assets/images/model/405-hover.png differ diff --git a/react-ui/public/assets/images/model/405.png b/react-ui/public/assets/images/model/405.png new file mode 100644 index 00000000..10bb9a70 Binary files /dev/null and b/react-ui/public/assets/images/model/405.png differ diff --git a/react-ui/public/assets/images/model/406-hover.png b/react-ui/public/assets/images/model/406-hover.png new file mode 100644 index 00000000..e9948f2b Binary files /dev/null and b/react-ui/public/assets/images/model/406-hover.png differ diff --git a/react-ui/public/assets/images/model/406.png b/react-ui/public/assets/images/model/406.png new file mode 100644 index 00000000..80769f13 Binary files /dev/null and b/react-ui/public/assets/images/model/406.png differ diff --git a/react-ui/public/assets/images/model/407-hover.png b/react-ui/public/assets/images/model/407-hover.png new file mode 100644 index 00000000..0193f68a Binary files /dev/null and b/react-ui/public/assets/images/model/407-hover.png differ diff --git a/react-ui/public/assets/images/model/407.png b/react-ui/public/assets/images/model/407.png new file mode 100644 index 00000000..9bd4651d Binary files /dev/null and b/react-ui/public/assets/images/model/407.png differ diff --git a/react-ui/public/assets/images/model/408-hover.png b/react-ui/public/assets/images/model/408-hover.png new file mode 100644 index 00000000..a42c9e59 Binary files /dev/null and b/react-ui/public/assets/images/model/408-hover.png differ diff --git a/react-ui/public/assets/images/model/408.png b/react-ui/public/assets/images/model/408.png new file mode 100644 index 00000000..17d23dc6 Binary files /dev/null and b/react-ui/public/assets/images/model/408.png differ diff --git a/react-ui/public/assets/images/model/409-hover.png b/react-ui/public/assets/images/model/409-hover.png new file mode 100644 index 00000000..0cb3ff30 Binary files /dev/null and b/react-ui/public/assets/images/model/409-hover.png differ diff --git a/react-ui/public/assets/images/model/409.png b/react-ui/public/assets/images/model/409.png new file mode 100644 index 00000000..5f0c3014 Binary files /dev/null and b/react-ui/public/assets/images/model/409.png differ diff --git a/react-ui/public/assets/images/model/410-hover.png b/react-ui/public/assets/images/model/410-hover.png new file mode 100644 index 00000000..84da02c1 Binary files /dev/null and b/react-ui/public/assets/images/model/410-hover.png differ diff --git a/react-ui/public/assets/images/model/410.png b/react-ui/public/assets/images/model/410.png new file mode 100644 index 00000000..50175005 Binary files /dev/null and b/react-ui/public/assets/images/model/410.png differ diff --git a/react-ui/public/assets/images/model/411-hover.png b/react-ui/public/assets/images/model/411-hover.png new file mode 100644 index 00000000..1ed50a25 Binary files /dev/null and b/react-ui/public/assets/images/model/411-hover.png differ diff --git a/react-ui/public/assets/images/model/411.png b/react-ui/public/assets/images/model/411.png new file mode 100644 index 00000000..75e1f70c Binary files /dev/null and b/react-ui/public/assets/images/model/411.png differ diff --git a/react-ui/public/assets/images/model/412-hover.png b/react-ui/public/assets/images/model/412-hover.png new file mode 100644 index 00000000..c2811d1a Binary files /dev/null and b/react-ui/public/assets/images/model/412-hover.png differ diff --git a/react-ui/public/assets/images/model/412.png b/react-ui/public/assets/images/model/412.png new file mode 100644 index 00000000..9c393719 Binary files /dev/null and b/react-ui/public/assets/images/model/412.png differ diff --git a/react-ui/public/assets/images/model/413-hover.png b/react-ui/public/assets/images/model/413-hover.png new file mode 100644 index 00000000..781784ed Binary files /dev/null and b/react-ui/public/assets/images/model/413-hover.png differ diff --git a/react-ui/public/assets/images/model/413.png b/react-ui/public/assets/images/model/413.png new file mode 100644 index 00000000..1a97d2cb Binary files /dev/null and b/react-ui/public/assets/images/model/413.png differ diff --git a/react-ui/public/assets/images/model/414-hover.png b/react-ui/public/assets/images/model/414-hover.png new file mode 100644 index 00000000..359db025 Binary files /dev/null and b/react-ui/public/assets/images/model/414-hover.png differ diff --git a/react-ui/public/assets/images/model/414.png b/react-ui/public/assets/images/model/414.png new file mode 100644 index 00000000..c154cb8f Binary files /dev/null and b/react-ui/public/assets/images/model/414.png differ diff --git a/react-ui/public/assets/images/model/415-hover.png b/react-ui/public/assets/images/model/415-hover.png new file mode 100644 index 00000000..47a5feb2 Binary files /dev/null and b/react-ui/public/assets/images/model/415-hover.png differ diff --git a/react-ui/public/assets/images/model/415.png b/react-ui/public/assets/images/model/415.png new file mode 100644 index 00000000..0155b437 Binary files /dev/null and b/react-ui/public/assets/images/model/415.png differ diff --git a/react-ui/public/assets/images/model/416-hover.png b/react-ui/public/assets/images/model/416-hover.png new file mode 100644 index 00000000..223f57fd Binary files /dev/null and b/react-ui/public/assets/images/model/416-hover.png differ diff --git a/react-ui/public/assets/images/model/416.png b/react-ui/public/assets/images/model/416.png new file mode 100644 index 00000000..654e3f00 Binary files /dev/null and b/react-ui/public/assets/images/model/416.png differ diff --git a/react-ui/public/assets/images/model/417-hover.png b/react-ui/public/assets/images/model/417-hover.png new file mode 100644 index 00000000..bd84640b Binary files /dev/null and b/react-ui/public/assets/images/model/417-hover.png differ diff --git a/react-ui/public/assets/images/model/417.png b/react-ui/public/assets/images/model/417.png new file mode 100644 index 00000000..c4cd1a1e Binary files /dev/null and b/react-ui/public/assets/images/model/417.png differ diff --git a/react-ui/public/assets/images/model/418-hover.png b/react-ui/public/assets/images/model/418-hover.png new file mode 100644 index 00000000..6e731363 Binary files /dev/null and b/react-ui/public/assets/images/model/418-hover.png differ diff --git a/react-ui/public/assets/images/model/418.png b/react-ui/public/assets/images/model/418.png new file mode 100644 index 00000000..f17f408e Binary files /dev/null and b/react-ui/public/assets/images/model/418.png differ diff --git a/react-ui/public/assets/images/model/419-hover.png b/react-ui/public/assets/images/model/419-hover.png new file mode 100644 index 00000000..d8eb74ed Binary files /dev/null and b/react-ui/public/assets/images/model/419-hover.png differ diff --git a/react-ui/public/assets/images/model/419.png b/react-ui/public/assets/images/model/419.png new file mode 100644 index 00000000..e63a5e13 Binary files /dev/null and b/react-ui/public/assets/images/model/419.png differ diff --git a/react-ui/public/assets/images/model/420-hover.png b/react-ui/public/assets/images/model/420-hover.png new file mode 100644 index 00000000..692e3731 Binary files /dev/null and b/react-ui/public/assets/images/model/420-hover.png differ diff --git a/react-ui/public/assets/images/model/420.png b/react-ui/public/assets/images/model/420.png new file mode 100644 index 00000000..e9747894 Binary files /dev/null and b/react-ui/public/assets/images/model/420.png differ diff --git a/react-ui/public/assets/images/model/421-hover.png b/react-ui/public/assets/images/model/421-hover.png new file mode 100644 index 00000000..88596858 Binary files /dev/null and b/react-ui/public/assets/images/model/421-hover.png differ diff --git a/react-ui/public/assets/images/model/421.png b/react-ui/public/assets/images/model/421.png new file mode 100644 index 00000000..ec5f9da8 Binary files /dev/null and b/react-ui/public/assets/images/model/421.png differ diff --git a/react-ui/public/assets/images/model/422-hover.png b/react-ui/public/assets/images/model/422-hover.png new file mode 100644 index 00000000..44f36292 Binary files /dev/null and b/react-ui/public/assets/images/model/422-hover.png differ diff --git a/react-ui/public/assets/images/model/422.png b/react-ui/public/assets/images/model/422.png new file mode 100644 index 00000000..5d60e0d1 Binary files /dev/null and b/react-ui/public/assets/images/model/422.png differ diff --git a/react-ui/public/assets/images/model/423-hover.png b/react-ui/public/assets/images/model/423-hover.png new file mode 100644 index 00000000..cb160d8d Binary files /dev/null and b/react-ui/public/assets/images/model/423-hover.png differ diff --git a/react-ui/public/assets/images/model/423.png b/react-ui/public/assets/images/model/423.png new file mode 100644 index 00000000..52419ee3 Binary files /dev/null and b/react-ui/public/assets/images/model/423.png differ diff --git a/react-ui/public/assets/材料科研软件平台使用文档-v1.0.pdf b/react-ui/public/assets/材料科研软件平台使用文档-v1.0.pdf new file mode 100644 index 00000000..a1341514 Binary files /dev/null and b/react-ui/public/assets/材料科研软件平台使用文档-v1.0.pdf differ diff --git a/react-ui/public/favicon-cc.ico b/react-ui/public/favicon-cc.ico new file mode 100644 index 00000000..4d544cb1 Binary files /dev/null and b/react-ui/public/favicon-cc.ico differ diff --git a/react-ui/public/favicon.ico b/react-ui/public/favicon.ico new file mode 100644 index 00000000..408b8a23 Binary files /dev/null and b/react-ui/public/favicon.ico differ diff --git a/react-ui/public/fonts/ALIBABA-PUHUITI-BOLD.TTF b/react-ui/public/fonts/ALIBABA-PUHUITI-BOLD.TTF new file mode 100644 index 00000000..af57de0e Binary files /dev/null and b/react-ui/public/fonts/ALIBABA-PUHUITI-BOLD.TTF differ diff --git a/react-ui/public/fonts/ALIBABA-PUHUITI-HEAVY.TTF b/react-ui/public/fonts/ALIBABA-PUHUITI-HEAVY.TTF new file mode 100644 index 00000000..7eb047f4 Binary files /dev/null and b/react-ui/public/fonts/ALIBABA-PUHUITI-HEAVY.TTF differ diff --git a/react-ui/public/fonts/ALIBABA-PUHUITI-LIGHT.TTF b/react-ui/public/fonts/ALIBABA-PUHUITI-LIGHT.TTF new file mode 100644 index 00000000..6962c163 Binary files /dev/null and b/react-ui/public/fonts/ALIBABA-PUHUITI-LIGHT.TTF differ diff --git a/react-ui/public/fonts/ALIBABA-PUHUITI-MEDIUM.TTF b/react-ui/public/fonts/ALIBABA-PUHUITI-MEDIUM.TTF new file mode 100644 index 00000000..7dc81344 Binary files /dev/null and b/react-ui/public/fonts/ALIBABA-PUHUITI-MEDIUM.TTF differ diff --git a/react-ui/public/fonts/ALIBABA-PUHUITI-REGULAR.TTF b/react-ui/public/fonts/ALIBABA-PUHUITI-REGULAR.TTF new file mode 100644 index 00000000..9f8ab8d8 Binary files /dev/null and b/react-ui/public/fonts/ALIBABA-PUHUITI-REGULAR.TTF differ diff --git a/react-ui/public/fonts/DingTalk-JinBuTi.ttf b/react-ui/public/fonts/DingTalk-JinBuTi.ttf new file mode 100644 index 00000000..c4efa55a Binary files /dev/null and b/react-ui/public/fonts/DingTalk-JinBuTi.ttf differ diff --git a/react-ui/public/fonts/DingTalk-JinBuTi.woff b/react-ui/public/fonts/DingTalk-JinBuTi.woff new file mode 100644 index 00000000..5a8efa8e Binary files /dev/null and b/react-ui/public/fonts/DingTalk-JinBuTi.woff differ diff --git a/react-ui/public/fonts/DingTalk-JinBuTi.woff2 b/react-ui/public/fonts/DingTalk-JinBuTi.woff2 new file mode 100644 index 00000000..c8d272a5 Binary files /dev/null and b/react-ui/public/fonts/DingTalk-JinBuTi.woff2 differ diff --git a/react-ui/public/fonts/WenYiHei.ttf b/react-ui/public/fonts/WenYiHei.ttf new file mode 100644 index 00000000..5e392798 Binary files /dev/null and b/react-ui/public/fonts/WenYiHei.ttf differ diff --git a/react-ui/public/fonts/YouSheBiaoTiHei.ttf b/react-ui/public/fonts/YouSheBiaoTiHei.ttf new file mode 100644 index 00000000..3729151a Binary files /dev/null and b/react-ui/public/fonts/YouSheBiaoTiHei.ttf differ diff --git a/react-ui/public/fonts/font.css b/react-ui/public/fonts/font.css new file mode 100644 index 00000000..cd3d6f12 --- /dev/null +++ b/react-ui/public/fonts/font.css @@ -0,0 +1,26 @@ +@font-face { + font-family: Alibaba; + src: url('./ALIBABA-PUHUITI-REGULAR.TTF'); + font-display: swap; +} + +@font-face { + font-family: 'DingTalk-JinBuTi'; + src: url('./DingTalk-JinBuTi.woff2') format('woff2'), /* 最优先使用 woff2 */ + url('./DingTalk-JinBuTi.woff') format('woff'), /* 兼容性较好的 woff */ + url('./DingTalk-JinBuTi.ttf') format('truetype'); /* ttf 作为备选 */ + font-display: swap; /* 优化页面加载时的字体显示 */ +} + +@font-face { + font-family: 'WenYiHei'; + src: url('./WenYiHei.ttf'); + font-display: swap; /* 优化页面加载时的字体显示 */ +} + + +@font-face { + font-family: 'YouSheBiaoTiHei'; + src: url('./YouSheBiaoTiHei.ttf'); + font-display: swap; /* 优化页面加载时的字体显示 */ +} \ No newline at end of file diff --git a/react-ui/public/mockServiceWorker.js b/react-ui/public/mockServiceWorker.js new file mode 100644 index 00000000..7f2f4b72 --- /dev/null +++ b/react-ui/public/mockServiceWorker.js @@ -0,0 +1,307 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.7.1' +const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/react-ui/public/scripts/loading.js b/react-ui/public/scripts/loading.js new file mode 100644 index 00000000..c1ced54c --- /dev/null +++ b/react-ui/public/scripts/loading.js @@ -0,0 +1,202 @@ +/** + * loading 占位 + * 解决首次加载时白屏的问题 + */ + (function () { + const _root = document.querySelector('#root'); + if (_root && _root.innerHTML === '') { + _root.innerHTML = ` + + +
+
+
+ + + + + + +
+
+
+ 正在加载资源 +
+
+ 初次加载资源可能需要较多时间 请耐心等待 +
+
+ `; + } +})(); diff --git a/react-ui/public/scripts/resize-breakpoint.js b/react-ui/public/scripts/resize-breakpoint.js new file mode 100644 index 00000000..f2c5f45e --- /dev/null +++ b/react-ui/public/scripts/resize-breakpoint.js @@ -0,0 +1,52 @@ +(function (doc, win) { + 'use strict'; + + // 配置项 + const config = { + // 断点设置(单位:px) + // 1440 1560 1680 1800 1920 2040 2160 2280 2400 2520 + breakpoints: [ + { minWidth: 2520, fontSize: 22 }, // 21 + { minWidth: 2280, fontSize: 20 }, // 19 + { minWidth: 2040, fontSize: 18 }, // 17 + { minWidth: 1800, fontSize: 16 }, // 15 + { minWidth: 1560, fontSize: 14 }, // 13 + { minWidth: 0, fontSize: 12 }, + ], + delay: 300 // 防抖延迟(ms) + }; + + const docEl = doc.documentElement; + const resizeEvt = 'orientationchange' in win ? 'orientationchange' : 'resize'; + let resizeTimeout; + + // 计算当前宽度对应的字体大小 + function calculateFontSize() { + const clientWidth = docEl.clientWidth || win.innerWidth; + if (!clientWidth) return; + + // 从大到小匹配断点 + const targetBreakpoint = config.breakpoints.find( + bp => clientWidth >= bp.minWidth + ); + + // 设置字体大小 + docEl.style.fontSize = targetBreakpoint.fontSize + 'px'; + + // 调试输出(可选) + console.debug('[REM-Resize]', + 'Width:', clientWidth + 'px', + 'Font-Size:', targetBreakpoint.fontSize + 'px'); + } + + // 防抖处理 + function handleResize() { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(calculateFontSize, config.delay); + } + + calculateFontSize(); + + // 初始化监听 + win.addEventListener(resizeEvt, handleResize, false); +})(document, window); \ No newline at end of file diff --git a/react-ui/public/scripts/resize.js b/react-ui/public/scripts/resize.js new file mode 100644 index 00000000..8434654d --- /dev/null +++ b/react-ui/public/scripts/resize.js @@ -0,0 +1,44 @@ +// rem-resize.js +(function (doc, win) { + 'use strict'; + + // 配置项 + const config = { + designWidth: 1920, // 设计稿宽度 + baseFontSize: 16, // 基础字体大小(设计稿下1rem = 16px) + minFontSize: 12, // 最小字体限制 + maxFontSize: 24, // 最大字体限制 + delay: 300, // 窗口变化时的延迟执行(ms) + }; + + const docEl = doc.documentElement; + const resizeEvt = 'orientationchange' in win ? 'orientationchange' : 'resize'; + let resizeTimeout; + + function calculateFontSize() { + const clientWidth = docEl.clientWidth || win.innerWidth; + if (!clientWidth) return; + + const fontSize = Math.min( + Math.max((clientWidth / config.designWidth) * config.baseFontSize, config.minFontSize), + config.maxFontSize, + ); + + docEl.style.fontSize = fontSize + 'px'; + + // 可选:调试输出 + if (win.console) { + console.debug('[REM-Resize]', 'width:', clientWidth, 'font-size:', fontSize + 'px'); + } + } + + function resizeHandler() { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(calculateFontSize, config.delay); + } + + calculateFontSize(); + + // 初始化监听 + win.addEventListener(resizeEvt, resizeHandler, false); +})(document, window); diff --git a/react-ui/src/access.ts b/react-ui/src/access.ts new file mode 100644 index 00000000..8c11f31b --- /dev/null +++ b/react-ui/src/access.ts @@ -0,0 +1,55 @@ +import { checkRole, matchPermission } from './utils/permission'; +/** + * @see https://umijs.org/zh-CN/plugins/plugin-access + * */ +export default function access(initialState: { currentUser?: API.CurrentUser } | undefined) { + const { currentUser } = initialState ?? {}; + const hasPerms = (perm: string) => { + return matchPermission(initialState?.currentUser?.permissions, perm); + }; + const roleFiler = (route: { authority: string[] }) => { + return checkRole(initialState?.currentUser?.roles, route.authority); + }; + return { + canAdmin: currentUser && currentUser.access === 'admin', + hasPerms, + roleFiler, + }; +} + +export function setSessionToken( + access_token: string | undefined, + refresh_token: string | undefined, + expireTime: number, +): void { + if (access_token) { + localStorage.setItem('access_token', access_token); + } else { + localStorage.removeItem('access_token'); + } + if (refresh_token) { + localStorage.setItem('refresh_token', refresh_token); + } else { + localStorage.removeItem('refresh_token'); + } + localStorage.setItem('expireTime', `${expireTime}`); +} + +export function getAccessToken() { + return localStorage.getItem('access_token'); +} + +export function getRefreshToken() { + return localStorage.getItem('refresh_token'); +} + +export function getTokenExpireTime() { + return localStorage.getItem('expireTime'); +} + +export function clearSessionToken() { + sessionStorage.removeItem('user'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('expireTime'); +} diff --git a/react-ui/src/app.tsx b/react-ui/src/app.tsx new file mode 100644 index 00000000..6de3d781 --- /dev/null +++ b/react-ui/src/app.tsx @@ -0,0 +1,262 @@ +import RightContent from '@/components/RightContent'; +import themes from '@/styles/theme.less'; +import { type GlobalInitialState } from '@/types'; +import { menuItemRender } from '@/utils/menuRender'; +import type { Settings as LayoutSettings } from '@ant-design/pro-components'; +import { RuntimeConfig, history } from '@umijs/max'; +import { useState } from 'react'; +import { RuntimeAntdConfig } from 'umi'; +import defaultSettings from '../config/defaultSettings'; +import '../public/fonts/font.css'; +import { getAccessToken } from './access'; +import ErrorBoundary from './components/ErrorBoundary'; +import PageContainer from './components/PageContainer'; +import './dayjsConfig'; +import { removeAllPageCacheState } from './hooks/useCacheState'; +import { globalGetSeverTime } from './hooks/useServerTime'; +import { + getRemoteMenu, + getRoutersInfo, + getUserInfo, + patchRouteWithRemoteMenus, + setRemoteMenu, +} from './services/session'; +import './styles/menu.less'; +import { isLoginPage, needAuth } from './utils'; +import { HomeUrl } from './utils/constant'; +import { closeAllModals } from './utils/modal'; +import { gotoHomePage } from './utils/ui'; +export { requestConfig as request } from './requestConfig'; + +/** + * @see https://umijs.org/zh-CN/plugins/plugin-initial-state + */ +export async function getInitialState(): Promise { + const fetchUserInfo = async () => { + globalGetSeverTime(); + try { + const response = await getUserInfo(); + return { + ...response.user, + avatar: response.user.avatar || require('@/assets/img/avatar-default.png'), + permissions: response.permissions, + roleNames: response.roles, + } as API.CurrentUser; + } catch (error) { + console.error('getInitialState', error); + // gotoLoginPage(true); + gotoHomePage(); + } + return undefined; + }; + + const token = getAccessToken(); + if (token) { + const currentUser = await fetchUserInfo(); + return { + fetchUserInfo, + currentUser, + settings: defaultSettings as Partial, + collapsed: false, + }; + } + return { + fetchUserInfo, + settings: defaultSettings as Partial, + collapsed: false, + }; +} + +// ProLayout 支持的api https://procomponents.ant.design/components/layout +export const layout: RuntimeConfig['layout'] = ({ initialState }) => { + return { + ErrorBoundary: ErrorBoundary, + rightContentRender: () => , + menu: { + locale: false, + // 每当 initialState?.currentUser?.userid 发生修改时重新执行 request + params: { + userId: initialState?.currentUser?.userId, + }, + request: async () => { + if (!initialState?.currentUser?.userId) { + return []; + } + return getRemoteMenu(); + }, + }, + childrenRender: (children) => { + // 增加一个 loading 的状态 + // if (initialState?.loading) return ; + return {children}; + }, + collapsedButtonRender: false, + collapsed: initialState?.collapsed, + menuProps: { + onClick: () => { + // 点击菜单项,删除所有的页面 state 缓存 + removeAllPageCacheState(); + }, + }, + ...initialState?.settings, + logo: require('@/assets/img/logo.png'), + token: { + sider: { + colorTextMenu: themes['textColor'], + colorTextMenuSelected: themes['primaryColor'], + colorTextMenuActive: themes['primaryColor'], + colorTextMenuItemHover: themes['primaryColor'], + colorBgMenuItemSelected: 'rgba(197, 232, 255, 0.8)', + colorMenuBackground: themes['siderBGColor'], + }, + header: { + colorBgHeader: '#3a3da5', + heightLayoutHeader: 60, + colorHeaderTitle: 'white', + }, + }, + menuItemRender: menuItemRender(false), + subMenuItemRender: menuItemRender(true), + }; +}; + +export const onRouteChange: RuntimeConfig['onRouteChange'] = async (e) => { + // console.log('onRouteChange'); + + // 路由切换时,尤其是回退时,关闭打开的弹框 + closeAllModals(); + + const { location } = e; + const pathname = location.pathname; + const token = getAccessToken(); + // 没有 token,跳转到登录页面 + if (!token && needAuth(pathname)) { + gotoHomePage(); + return; + } + + // 有 token, 登录页面直接跳转到首页 + if (token && isLoginPage(pathname)) { + history.push(HomeUrl); + } + + const menus = getRemoteMenu(); + // 没有菜单,刷新页面 + if (menus === null && needAuth(pathname)) { + history.go(0); + } +}; + +export const patchRoutes: RuntimeConfig['patchRoutes'] = () => { + //console.log('patchRoutes', e); +}; + +export const patchClientRoutes: RuntimeConfig['patchClientRoutes'] = (e) => { + // console.log('patchClientRoutes', e); + patchRouteWithRemoteMenus(e.routes); +}; + +export function render(oldRender: () => void) { + // console.log('render'); + const token = getAccessToken(); + if (!token) { + oldRender(); + return; + } + + // 有 token,获取路由 + getRoutersInfo() + .then((res) => { + setRemoteMenu(res); + oldRender(); + }) + .catch(() => { + oldRender(); + }); +} + +export const useQiankunStateForSlave = () => { + const [globalState, setGlobalState] = useState({ + slogan: 'Hello MicroFrontend', + }); + + return { + globalState, + setGlobalState, + }; +}; + +// 主题修改 +export const antd: RuntimeAntdConfig = (memo) => { + memo.theme ??= {}; + memo.theme.token = { + colorPrimary: themes['primaryColor'], + colorPrimaryHover: themes['primaryHoverColor'], + colorPrimaryActive: themes['primaryActiveColor'], + colorPrimaryText: themes['primaryColor'], + colorPrimaryTextHover: themes['primaryHoverColor'], + colorPrimaryTextActive: themes['primaryActiveColor'], + // colorPrimaryBg: 'rgba(81, 76, 249, 0.07)', + colorSuccess: themes['successColor'], + colorError: themes['errorColor'], + colorWarning: themes['warningColor'], + colorLink: themes['primaryColor'], + colorText: themes['textColor'], + fontSize: parseInt(themes['fontSize']), + controlHeightLG: 46, + }; + memo.theme.components ??= {}; + memo.theme.components.Tabs = {}; + memo.theme.components.Button = { + defaultBg: 'rgba(22, 100, 255, 0.06)', + defaultBorderColor: 'rgba(22, 100, 255, 0.11)', + defaultColor: themes['textColor'], + defaultHoverBg: 'rgba(22, 100, 255, 0.06)', + defaultHoverBorderColor: 'rgba(22, 100, 255, 0.5)', + defaultHoverColor: '#3F7FFF', + defaultActiveBg: 'rgba(22, 100, 255, 0.12)', + defaultActiveBorderColor: 'rgba(22, 100, 255, 0.75)', + defaultActiveColor: themes['primaryColor'], + contentFontSize: parseInt(themes['fontSize']), + }; + memo.theme.components.Input = { + inputFontSize: parseInt(themes['fontSizeInput']), + inputFontSizeLG: parseInt(themes['fontSizeInputLg']), + paddingBlockLG: 10, + }; + memo.theme.components.Select = { + singleItemHeightLG: 46, + optionSelectedColor: themes['primaryColor'], + }; + memo.theme.components.Table = { + headerBg: 'rgba(242, 244, 247, 0.36)', + headerBorderRadius: 4, + // rowSelectedBg: 'rgba(22, 100, 255, 0.05)', 固定列时,横向滑动导致重叠 + }; + memo.theme.components.Tabs = { + titleFontSize: 16, + }; + memo.theme.components.Form = { + labelColor: 'rgba(29, 29, 32, 0.8);', + }; + memo.theme.components.Breadcrumb = { + iconFontSize: parseInt(themes['fontSize']), + linkColor: 'rgba(29, 29, 32, 0.7)', + separatorColor: 'rgba(29, 29, 32, 0.7)', + }; + memo.theme.components.Tree = { + directoryNodeSelectedBg: 'rgba(22, 100, 255, 0.7)', + }; + + memo.theme.cssVar = true; + memo.theme.hashed = false; + + memo.appConfig = { + message: { + // 配置 message 最大显示数,超过限制时,最早的消息会被自动关闭 + maxCount: 3, + }, + }; + + return memo; +}; diff --git a/react-ui/src/assets/img/404.png b/react-ui/src/assets/img/404.png new file mode 100644 index 00000000..21e86122 Binary files /dev/null and b/react-ui/src/assets/img/404.png differ diff --git a/react-ui/src/assets/img/avatar-default.png b/react-ui/src/assets/img/avatar-default.png new file mode 100644 index 00000000..b2022f38 Binary files /dev/null and b/react-ui/src/assets/img/avatar-default.png differ diff --git a/react-ui/src/assets/img/blue-triangle.png b/react-ui/src/assets/img/blue-triangle.png new file mode 100644 index 00000000..b82879d9 Binary files /dev/null and b/react-ui/src/assets/img/blue-triangle.png differ diff --git a/react-ui/src/assets/img/clock.png b/react-ui/src/assets/img/clock.png new file mode 100644 index 00000000..a2068364 Binary files /dev/null and b/react-ui/src/assets/img/clock.png differ diff --git a/react-ui/src/assets/img/code-name-icon.png b/react-ui/src/assets/img/code-name-icon.png new file mode 100644 index 00000000..1e7991a9 Binary files /dev/null and b/react-ui/src/assets/img/code-name-icon.png differ diff --git a/react-ui/src/assets/img/confirm-icon.png b/react-ui/src/assets/img/confirm-icon.png new file mode 100644 index 00000000..6865b719 Binary files /dev/null and b/react-ui/src/assets/img/confirm-icon.png differ diff --git a/react-ui/src/assets/img/copy-icon.png b/react-ui/src/assets/img/copy-icon.png new file mode 100644 index 00000000..b0eebfe8 Binary files /dev/null and b/react-ui/src/assets/img/copy-icon.png differ diff --git a/react-ui/src/assets/img/creatBy.png b/react-ui/src/assets/img/creatBy.png new file mode 100644 index 00000000..12f2e384 Binary files /dev/null and b/react-ui/src/assets/img/creatBy.png differ diff --git a/react-ui/src/assets/img/create-experiment.png b/react-ui/src/assets/img/create-experiment.png new file mode 100644 index 00000000..b086fefa Binary files /dev/null and b/react-ui/src/assets/img/create-experiment.png differ diff --git a/react-ui/src/assets/img/dataset-config-icon.png b/react-ui/src/assets/img/dataset-config-icon.png new file mode 100644 index 00000000..1afc4f72 Binary files /dev/null and b/react-ui/src/assets/img/dataset-config-icon.png differ diff --git a/react-ui/src/assets/img/dataset-intro-top.png b/react-ui/src/assets/img/dataset-intro-top.png new file mode 100644 index 00000000..10604dd9 Binary files /dev/null and b/react-ui/src/assets/img/dataset-intro-top.png differ diff --git a/react-ui/src/assets/img/dataset-version.png b/react-ui/src/assets/img/dataset-version.png new file mode 100644 index 00000000..c17d46cf Binary files /dev/null and b/react-ui/src/assets/img/dataset-version.png differ diff --git a/react-ui/src/assets/img/delete-icon.png b/react-ui/src/assets/img/delete-icon.png new file mode 100644 index 00000000..310f8bb8 Binary files /dev/null and b/react-ui/src/assets/img/delete-icon.png differ diff --git a/react-ui/src/assets/img/duty-message.png b/react-ui/src/assets/img/duty-message.png new file mode 100644 index 00000000..3b187643 Binary files /dev/null and b/react-ui/src/assets/img/duty-message.png differ diff --git a/react-ui/src/assets/img/edit-experiment.png b/react-ui/src/assets/img/edit-experiment.png new file mode 100644 index 00000000..d41a8dbf Binary files /dev/null and b/react-ui/src/assets/img/edit-experiment.png differ diff --git a/react-ui/src/assets/img/editor-parameter.png b/react-ui/src/assets/img/editor-parameter.png new file mode 100644 index 00000000..06fb4f2b Binary files /dev/null and b/react-ui/src/assets/img/editor-parameter.png differ diff --git a/react-ui/src/assets/img/experiment-pending.png b/react-ui/src/assets/img/experiment-pending.png new file mode 100644 index 00000000..ceefa027 Binary files /dev/null and b/react-ui/src/assets/img/experiment-pending.png differ diff --git a/react-ui/src/assets/img/experiment-running.png b/react-ui/src/assets/img/experiment-running.png new file mode 100644 index 00000000..d4121030 Binary files /dev/null and b/react-ui/src/assets/img/experiment-running.png differ diff --git a/react-ui/src/assets/img/functional-material.png b/react-ui/src/assets/img/functional-material.png new file mode 100644 index 00000000..9eb39a70 Binary files /dev/null and b/react-ui/src/assets/img/functional-material.png differ diff --git a/react-ui/src/assets/img/home/app-logo-dark.png b/react-ui/src/assets/img/home/app-logo-dark.png new file mode 100644 index 00000000..ff9f95da Binary files /dev/null and b/react-ui/src/assets/img/home/app-logo-dark.png differ diff --git a/react-ui/src/assets/img/home/app-logo.png b/react-ui/src/assets/img/home/app-logo.png new file mode 100644 index 00000000..e5342fcb Binary files /dev/null and b/react-ui/src/assets/img/home/app-logo.png differ diff --git a/react-ui/src/assets/img/home/code-arrow.png b/react-ui/src/assets/img/home/code-arrow.png new file mode 100644 index 00000000..7446812d Binary files /dev/null and b/react-ui/src/assets/img/home/code-arrow.png differ diff --git a/react-ui/src/assets/img/home/code-bg.png b/react-ui/src/assets/img/home/code-bg.png new file mode 100644 index 00000000..194c033a Binary files /dev/null and b/react-ui/src/assets/img/home/code-bg.png differ diff --git a/react-ui/src/assets/img/home/code-item-bg-hover.png b/react-ui/src/assets/img/home/code-item-bg-hover.png new file mode 100644 index 00000000..d0b2fc65 Binary files /dev/null and b/react-ui/src/assets/img/home/code-item-bg-hover.png differ diff --git a/react-ui/src/assets/img/home/code-item-bg.png b/react-ui/src/assets/img/home/code-item-bg.png new file mode 100644 index 00000000..325500e9 Binary files /dev/null and b/react-ui/src/assets/img/home/code-item-bg.png differ diff --git a/react-ui/src/assets/img/home/code.png b/react-ui/src/assets/img/home/code.png new file mode 100644 index 00000000..604107f9 Binary files /dev/null and b/react-ui/src/assets/img/home/code.png differ diff --git a/react-ui/src/assets/img/home/dataset-arrow-right.png b/react-ui/src/assets/img/home/dataset-arrow-right.png new file mode 100644 index 00000000..6a4d1a35 Binary files /dev/null and b/react-ui/src/assets/img/home/dataset-arrow-right.png differ diff --git a/react-ui/src/assets/img/home/dataset-bg.png b/react-ui/src/assets/img/home/dataset-bg.png new file mode 100644 index 00000000..c822ebf4 Binary files /dev/null and b/react-ui/src/assets/img/home/dataset-bg.png differ diff --git a/react-ui/src/assets/img/home/dataset-item-bg.png b/react-ui/src/assets/img/home/dataset-item-bg.png new file mode 100644 index 00000000..241b4f4b Binary files /dev/null and b/react-ui/src/assets/img/home/dataset-item-bg.png differ diff --git a/react-ui/src/assets/img/home/dataset.png b/react-ui/src/assets/img/home/dataset.png new file mode 100644 index 00000000..fcd669ca Binary files /dev/null and b/react-ui/src/assets/img/home/dataset.png differ diff --git a/react-ui/src/assets/img/home/default-avatar.png b/react-ui/src/assets/img/home/default-avatar.png new file mode 100644 index 00000000..66dc36ca Binary files /dev/null and b/react-ui/src/assets/img/home/default-avatar.png differ diff --git a/react-ui/src/assets/img/home/footer-bg.png b/react-ui/src/assets/img/home/footer-bg.png new file mode 100644 index 00000000..618a77b5 Binary files /dev/null and b/react-ui/src/assets/img/home/footer-bg.png differ diff --git a/react-ui/src/assets/img/home/header-bg-mini.png b/react-ui/src/assets/img/home/header-bg-mini.png new file mode 100644 index 00000000..22632022 Binary files /dev/null and b/react-ui/src/assets/img/home/header-bg-mini.png differ diff --git a/react-ui/src/assets/img/home/header-bg.png b/react-ui/src/assets/img/home/header-bg.png new file mode 100644 index 00000000..db0d3c17 Binary files /dev/null and b/react-ui/src/assets/img/home/header-bg.png differ diff --git a/react-ui/src/assets/img/home/image.png b/react-ui/src/assets/img/home/image.png new file mode 100644 index 00000000..fac6397a Binary files /dev/null and b/react-ui/src/assets/img/home/image.png differ diff --git a/react-ui/src/assets/img/home/mirror-arrow.png b/react-ui/src/assets/img/home/mirror-arrow.png new file mode 100644 index 00000000..0cd71d90 Binary files /dev/null and b/react-ui/src/assets/img/home/mirror-arrow.png differ diff --git a/react-ui/src/assets/img/home/mirror-version.png b/react-ui/src/assets/img/home/mirror-version.png new file mode 100644 index 00000000..b3d33cfc Binary files /dev/null and b/react-ui/src/assets/img/home/mirror-version.png differ diff --git a/react-ui/src/assets/img/home/model-between-dataset.png b/react-ui/src/assets/img/home/model-between-dataset.png new file mode 100644 index 00000000..d0384a4e Binary files /dev/null and b/react-ui/src/assets/img/home/model-between-dataset.png differ diff --git a/react-ui/src/assets/img/home/model-bg.png b/react-ui/src/assets/img/home/model-bg.png new file mode 100644 index 00000000..2238efa0 Binary files /dev/null and b/react-ui/src/assets/img/home/model-bg.png differ diff --git a/react-ui/src/assets/img/home/model-item-bg-hover.png b/react-ui/src/assets/img/home/model-item-bg-hover.png new file mode 100644 index 00000000..e428905c Binary files /dev/null and b/react-ui/src/assets/img/home/model-item-bg-hover.png differ diff --git a/react-ui/src/assets/img/home/model-item-bg-hover2.png b/react-ui/src/assets/img/home/model-item-bg-hover2.png new file mode 100644 index 00000000..70f48227 Binary files /dev/null and b/react-ui/src/assets/img/home/model-item-bg-hover2.png differ diff --git a/react-ui/src/assets/img/home/model-item-bg.png b/react-ui/src/assets/img/home/model-item-bg.png new file mode 100644 index 00000000..61e96565 Binary files /dev/null and b/react-ui/src/assets/img/home/model-item-bg.png differ diff --git a/react-ui/src/assets/img/home/model-item-hot-hover.png b/react-ui/src/assets/img/home/model-item-hot-hover.png new file mode 100644 index 00000000..4fcc6c27 Binary files /dev/null and b/react-ui/src/assets/img/home/model-item-hot-hover.png differ diff --git a/react-ui/src/assets/img/home/model-item-hot.png b/react-ui/src/assets/img/home/model-item-hot.png new file mode 100644 index 00000000..bda601b1 Binary files /dev/null and b/react-ui/src/assets/img/home/model-item-hot.png differ diff --git a/react-ui/src/assets/img/home/model.png b/react-ui/src/assets/img/home/model.png new file mode 100644 index 00000000..7383692e Binary files /dev/null and b/react-ui/src/assets/img/home/model.png differ diff --git a/react-ui/src/assets/img/home/right-arrow-hover.png b/react-ui/src/assets/img/home/right-arrow-hover.png new file mode 100644 index 00000000..31f968fd Binary files /dev/null and b/react-ui/src/assets/img/home/right-arrow-hover.png differ diff --git a/react-ui/src/assets/img/home/right-arrow.png b/react-ui/src/assets/img/home/right-arrow.png new file mode 100644 index 00000000..d89934dd Binary files /dev/null and b/react-ui/src/assets/img/home/right-arrow.png differ diff --git a/react-ui/src/assets/img/home/service-bg.png b/react-ui/src/assets/img/home/service-bg.png new file mode 100644 index 00000000..be9bdede Binary files /dev/null and b/react-ui/src/assets/img/home/service-bg.png differ diff --git a/react-ui/src/assets/img/home/service.png b/react-ui/src/assets/img/home/service.png new file mode 100644 index 00000000..a62fb0be Binary files /dev/null and b/react-ui/src/assets/img/home/service.png differ diff --git a/react-ui/src/assets/img/home/service1.png b/react-ui/src/assets/img/home/service1.png new file mode 100644 index 00000000..9009197d Binary files /dev/null and b/react-ui/src/assets/img/home/service1.png differ diff --git a/react-ui/src/assets/img/home/service2.png b/react-ui/src/assets/img/home/service2.png new file mode 100644 index 00000000..07bc8eab Binary files /dev/null and b/react-ui/src/assets/img/home/service2.png differ diff --git a/react-ui/src/assets/img/home/service3.png b/react-ui/src/assets/img/home/service3.png new file mode 100644 index 00000000..17bce241 Binary files /dev/null and b/react-ui/src/assets/img/home/service3.png differ diff --git a/react-ui/src/assets/img/home/service4.png b/react-ui/src/assets/img/home/service4.png new file mode 100644 index 00000000..471cf98a Binary files /dev/null and b/react-ui/src/assets/img/home/service4.png differ diff --git a/react-ui/src/assets/img/home/statistics-bg.png b/react-ui/src/assets/img/home/statistics-bg.png new file mode 100644 index 00000000..0386de44 Binary files /dev/null and b/react-ui/src/assets/img/home/statistics-bg.png differ diff --git a/react-ui/src/assets/img/home/timestamp.png b/react-ui/src/assets/img/home/timestamp.png new file mode 100644 index 00000000..7087131a Binary files /dev/null and b/react-ui/src/assets/img/home/timestamp.png differ diff --git a/react-ui/src/assets/img/home/user-avatar-big.png b/react-ui/src/assets/img/home/user-avatar-big.png new file mode 100644 index 00000000..ed548da3 Binary files /dev/null and b/react-ui/src/assets/img/home/user-avatar-big.png differ diff --git a/react-ui/src/assets/img/home/user-avatar.png b/react-ui/src/assets/img/home/user-avatar.png new file mode 100644 index 00000000..067aa252 Binary files /dev/null and b/react-ui/src/assets/img/home/user-avatar.png differ diff --git a/react-ui/src/assets/img/home/路径 17816@2x (1).png b/react-ui/src/assets/img/home/路径 17816@2x (1).png new file mode 100644 index 00000000..a57d2466 Binary files /dev/null and b/react-ui/src/assets/img/home/路径 17816@2x (1).png differ diff --git a/react-ui/src/assets/img/home/路径 17816@2x.png b/react-ui/src/assets/img/home/路径 17816@2x.png new file mode 100644 index 00000000..f4db7070 Binary files /dev/null and b/react-ui/src/assets/img/home/路径 17816@2x.png differ diff --git a/react-ui/src/assets/img/login-ai-logo.png b/react-ui/src/assets/img/login-ai-logo.png new file mode 100644 index 00000000..f5773278 Binary files /dev/null and b/react-ui/src/assets/img/login-ai-logo.png differ diff --git a/react-ui/src/assets/img/login-captcha.png b/react-ui/src/assets/img/login-captcha.png new file mode 100644 index 00000000..fa21e9b7 Binary files /dev/null and b/react-ui/src/assets/img/login-captcha.png differ diff --git a/react-ui/src/assets/img/login-left-image.png b/react-ui/src/assets/img/login-left-image.png new file mode 100644 index 00000000..017f1068 Binary files /dev/null and b/react-ui/src/assets/img/login-left-image.png differ diff --git a/react-ui/src/assets/img/login-password.png b/react-ui/src/assets/img/login-password.png new file mode 100644 index 00000000..5afc6d99 Binary files /dev/null and b/react-ui/src/assets/img/login-password.png differ diff --git a/react-ui/src/assets/img/login-user.png b/react-ui/src/assets/img/login-user.png new file mode 100644 index 00000000..ee84ebe9 Binary files /dev/null and b/react-ui/src/assets/img/login-user.png differ diff --git a/react-ui/src/assets/img/logo-cc.png b/react-ui/src/assets/img/logo-cc.png new file mode 100644 index 00000000..cae91fe5 Binary files /dev/null and b/react-ui/src/assets/img/logo-cc.png differ diff --git a/react-ui/src/assets/img/logo.png b/react-ui/src/assets/img/logo.png new file mode 100644 index 00000000..e2fbcfe5 Binary files /dev/null and b/react-ui/src/assets/img/logo.png differ diff --git a/react-ui/src/assets/img/message/at-hover.png b/react-ui/src/assets/img/message/at-hover.png new file mode 100644 index 00000000..57aa81db Binary files /dev/null and b/react-ui/src/assets/img/message/at-hover.png differ diff --git a/react-ui/src/assets/img/message/at.png b/react-ui/src/assets/img/message/at.png new file mode 100644 index 00000000..e1cd2875 Binary files /dev/null and b/react-ui/src/assets/img/message/at.png differ diff --git a/react-ui/src/assets/img/message/content-bg.png b/react-ui/src/assets/img/message/content-bg.png new file mode 100644 index 00000000..dc31d537 Binary files /dev/null and b/react-ui/src/assets/img/message/content-bg.png differ diff --git a/react-ui/src/assets/img/message/menu-bg.png b/react-ui/src/assets/img/message/menu-bg.png new file mode 100644 index 00000000..c762354b Binary files /dev/null and b/react-ui/src/assets/img/message/menu-bg.png differ diff --git a/react-ui/src/assets/img/message/message-bg.png b/react-ui/src/assets/img/message/message-bg.png new file mode 100644 index 00000000..6a54cd26 Binary files /dev/null and b/react-ui/src/assets/img/message/message-bg.png differ diff --git a/react-ui/src/assets/img/message/red-point.png b/react-ui/src/assets/img/message/red-point.png new file mode 100644 index 00000000..bfd49b2e Binary files /dev/null and b/react-ui/src/assets/img/message/red-point.png differ diff --git a/react-ui/src/assets/img/message/system-hover.png b/react-ui/src/assets/img/message/system-hover.png new file mode 100644 index 00000000..31efbf60 Binary files /dev/null and b/react-ui/src/assets/img/message/system-hover.png differ diff --git a/react-ui/src/assets/img/message/system.png b/react-ui/src/assets/img/message/system.png new file mode 100644 index 00000000..4e23e3ec Binary files /dev/null and b/react-ui/src/assets/img/message/system.png differ diff --git a/react-ui/src/assets/img/message/trumpet-hover.png b/react-ui/src/assets/img/message/trumpet-hover.png new file mode 100644 index 00000000..82439bd6 Binary files /dev/null and b/react-ui/src/assets/img/message/trumpet-hover.png differ diff --git a/react-ui/src/assets/img/message/trumpet.png b/react-ui/src/assets/img/message/trumpet.png new file mode 100644 index 00000000..720aeb04 Binary files /dev/null and b/react-ui/src/assets/img/message/trumpet.png differ diff --git a/react-ui/src/assets/img/metrics-title-icon.png b/react-ui/src/assets/img/metrics-title-icon.png new file mode 100644 index 00000000..66cd461f Binary files /dev/null and b/react-ui/src/assets/img/metrics-title-icon.png differ diff --git a/react-ui/src/assets/img/mirror-basic.png b/react-ui/src/assets/img/mirror-basic.png new file mode 100644 index 00000000..f022b2ac Binary files /dev/null and b/react-ui/src/assets/img/mirror-basic.png differ diff --git a/react-ui/src/assets/img/mirror-version.png b/react-ui/src/assets/img/mirror-version.png new file mode 100644 index 00000000..c9e69646 Binary files /dev/null and b/react-ui/src/assets/img/mirror-version.png differ diff --git a/react-ui/src/assets/img/missing-back.png b/react-ui/src/assets/img/missing-back.png new file mode 100644 index 00000000..5d9ad049 Binary files /dev/null and b/react-ui/src/assets/img/missing-back.png differ diff --git a/react-ui/src/assets/img/modal-back.png b/react-ui/src/assets/img/modal-back.png new file mode 100644 index 00000000..883b277f Binary files /dev/null and b/react-ui/src/assets/img/modal-back.png differ diff --git a/react-ui/src/assets/img/modal-code-config.png b/react-ui/src/assets/img/modal-code-config.png new file mode 100644 index 00000000..776d4825 Binary files /dev/null and b/react-ui/src/assets/img/modal-code-config.png differ diff --git a/react-ui/src/assets/img/modal-parameter.png b/react-ui/src/assets/img/modal-parameter.png new file mode 100644 index 00000000..d717b59a Binary files /dev/null and b/react-ui/src/assets/img/modal-parameter.png differ diff --git a/react-ui/src/assets/img/modal-select-dataset.png b/react-ui/src/assets/img/modal-select-dataset.png new file mode 100644 index 00000000..6a99cc24 Binary files /dev/null and b/react-ui/src/assets/img/modal-select-dataset.png differ diff --git a/react-ui/src/assets/img/modal-select-mirror.png b/react-ui/src/assets/img/modal-select-mirror.png new file mode 100644 index 00000000..93e12760 Binary files /dev/null and b/react-ui/src/assets/img/modal-select-mirror.png differ diff --git a/react-ui/src/assets/img/modal-select-model.png b/react-ui/src/assets/img/modal-select-model.png new file mode 100644 index 00000000..7e67ec05 Binary files /dev/null and b/react-ui/src/assets/img/modal-select-model.png differ diff --git a/react-ui/src/assets/img/model-deployment.png b/react-ui/src/assets/img/model-deployment.png new file mode 100644 index 00000000..59a32d5f Binary files /dev/null and b/react-ui/src/assets/img/model-deployment.png differ diff --git a/react-ui/src/assets/img/model-metrics.png b/react-ui/src/assets/img/model-metrics.png new file mode 100644 index 00000000..3379db8a Binary files /dev/null and b/react-ui/src/assets/img/model-metrics.png differ diff --git a/react-ui/src/assets/img/molecular-material.png b/react-ui/src/assets/img/molecular-material.png new file mode 100644 index 00000000..5cd79357 Binary files /dev/null and b/react-ui/src/assets/img/molecular-material.png differ diff --git a/react-ui/src/assets/img/more-back.png b/react-ui/src/assets/img/more-back.png new file mode 100644 index 00000000..b9956ef5 Binary files /dev/null and b/react-ui/src/assets/img/more-back.png differ diff --git a/react-ui/src/assets/img/no-data.png b/react-ui/src/assets/img/no-data.png new file mode 100644 index 00000000..9ca6aead Binary files /dev/null and b/react-ui/src/assets/img/no-data.png differ diff --git a/react-ui/src/assets/img/page-title-bg.png b/react-ui/src/assets/img/page-title-bg.png new file mode 100644 index 00000000..9e01b8f3 Binary files /dev/null and b/react-ui/src/assets/img/page-title-bg.png differ diff --git a/react-ui/src/assets/img/pipeline-canvas-bg.png b/react-ui/src/assets/img/pipeline-canvas-bg.png new file mode 100644 index 00000000..c7e9864e Binary files /dev/null and b/react-ui/src/assets/img/pipeline-canvas-bg.png differ diff --git a/react-ui/src/assets/img/pipeline-warning.png b/react-ui/src/assets/img/pipeline-warning.png new file mode 100644 index 00000000..67b8c65c Binary files /dev/null and b/react-ui/src/assets/img/pipeline-warning.png differ diff --git a/react-ui/src/assets/img/popover-bg.png b/react-ui/src/assets/img/popover-bg.png new file mode 100644 index 00000000..d783c637 Binary files /dev/null and b/react-ui/src/assets/img/popover-bg.png differ diff --git a/react-ui/src/assets/img/resample-icon.png b/react-ui/src/assets/img/resample-icon.png new file mode 100644 index 00000000..fa24c1aa Binary files /dev/null and b/react-ui/src/assets/img/resample-icon.png differ diff --git a/react-ui/src/assets/img/robot.png b/react-ui/src/assets/img/robot.png new file mode 100644 index 00000000..9e179741 Binary files /dev/null and b/react-ui/src/assets/img/robot.png differ diff --git a/react-ui/src/assets/img/search-config-icon.png b/react-ui/src/assets/img/search-config-icon.png new file mode 100644 index 00000000..6cc8a78b Binary files /dev/null and b/react-ui/src/assets/img/search-config-icon.png differ diff --git a/react-ui/src/assets/img/service-version.png b/react-ui/src/assets/img/service-version.png new file mode 100644 index 00000000..08361ac9 Binary files /dev/null and b/react-ui/src/assets/img/service-version.png differ diff --git a/react-ui/src/assets/img/static-message.png b/react-ui/src/assets/img/static-message.png new file mode 100644 index 00000000..097f48a2 Binary files /dev/null and b/react-ui/src/assets/img/static-message.png differ diff --git a/react-ui/src/assets/img/tensor-board-export.png b/react-ui/src/assets/img/tensor-board-export.png new file mode 100644 index 00000000..46f296ee Binary files /dev/null and b/react-ui/src/assets/img/tensor-board-export.png differ diff --git a/react-ui/src/assets/img/tensor-board-failed.png b/react-ui/src/assets/img/tensor-board-failed.png new file mode 100644 index 00000000..e2d94392 Binary files /dev/null and b/react-ui/src/assets/img/tensor-board-failed.png differ diff --git a/react-ui/src/assets/img/tensor-board-pending.png b/react-ui/src/assets/img/tensor-board-pending.png new file mode 100644 index 00000000..f9eee3a9 Binary files /dev/null and b/react-ui/src/assets/img/tensor-board-pending.png differ diff --git a/react-ui/src/assets/img/tensor-board-running.png b/react-ui/src/assets/img/tensor-board-running.png new file mode 100644 index 00000000..7ed147ec Binary files /dev/null and b/react-ui/src/assets/img/tensor-board-running.png differ diff --git a/react-ui/src/assets/img/tensor-board-stop.png b/react-ui/src/assets/img/tensor-board-stop.png new file mode 100644 index 00000000..3a23a092 Binary files /dev/null and b/react-ui/src/assets/img/tensor-board-stop.png differ diff --git a/react-ui/src/assets/img/tensor-board-terminated.png b/react-ui/src/assets/img/tensor-board-terminated.png new file mode 100644 index 00000000..c234bba5 Binary files /dev/null and b/react-ui/src/assets/img/tensor-board-terminated.png differ diff --git a/react-ui/src/assets/img/tensor-board-unknown.png b/react-ui/src/assets/img/tensor-board-unknown.png new file mode 100644 index 00000000..843d0e5e Binary files /dev/null and b/react-ui/src/assets/img/tensor-board-unknown.png differ diff --git a/react-ui/src/assets/img/total-icon.png b/react-ui/src/assets/img/total-icon.png new file mode 100644 index 00000000..38c44a50 Binary files /dev/null and b/react-ui/src/assets/img/total-icon.png differ diff --git a/react-ui/src/assets/img/trial-config-icon.png b/react-ui/src/assets/img/trial-config-icon.png new file mode 100644 index 00000000..69408563 Binary files /dev/null and b/react-ui/src/assets/img/trial-config-icon.png differ diff --git a/react-ui/src/assets/img/usage-icon.png b/react-ui/src/assets/img/usage-icon.png new file mode 100644 index 00000000..cda2cfaf Binary files /dev/null and b/react-ui/src/assets/img/usage-icon.png differ diff --git a/react-ui/src/assets/img/user-avatar/1.png b/react-ui/src/assets/img/user-avatar/1.png new file mode 100644 index 00000000..bb55cbb8 Binary files /dev/null and b/react-ui/src/assets/img/user-avatar/1.png differ diff --git a/react-ui/src/assets/img/user-avatar/2.png b/react-ui/src/assets/img/user-avatar/2.png new file mode 100644 index 00000000..941690cc Binary files /dev/null and b/react-ui/src/assets/img/user-avatar/2.png differ diff --git a/react-ui/src/assets/img/user-avatar/3.png b/react-ui/src/assets/img/user-avatar/3.png new file mode 100644 index 00000000..36b9a8fd Binary files /dev/null and b/react-ui/src/assets/img/user-avatar/3.png differ diff --git a/react-ui/src/assets/img/user-avatar/4.png b/react-ui/src/assets/img/user-avatar/4.png new file mode 100644 index 00000000..5f59cd8f Binary files /dev/null and b/react-ui/src/assets/img/user-avatar/4.png differ diff --git a/react-ui/src/assets/img/user-avatar/5.png b/react-ui/src/assets/img/user-avatar/5.png new file mode 100644 index 00000000..7f815419 Binary files /dev/null and b/react-ui/src/assets/img/user-avatar/5.png differ diff --git a/react-ui/src/assets/img/user-avatar/6.png b/react-ui/src/assets/img/user-avatar/6.png new file mode 100644 index 00000000..df2b149b Binary files /dev/null and b/react-ui/src/assets/img/user-avatar/6.png differ diff --git a/react-ui/src/assets/img/user-avatar/7.png b/react-ui/src/assets/img/user-avatar/7.png new file mode 100644 index 00000000..3526a286 Binary files /dev/null and b/react-ui/src/assets/img/user-avatar/7.png differ diff --git a/react-ui/src/assets/img/user-avatar/8.png b/react-ui/src/assets/img/user-avatar/8.png new file mode 100644 index 00000000..e672d96d Binary files /dev/null and b/react-ui/src/assets/img/user-avatar/8.png differ diff --git a/react-ui/src/assets/img/user-points-bg.png b/react-ui/src/assets/img/user-points-bg.png new file mode 100644 index 00000000..3f25b128 Binary files /dev/null and b/react-ui/src/assets/img/user-points-bg.png differ diff --git a/react-ui/src/assets/img/workspace-experiment.png b/react-ui/src/assets/img/workspace-experiment.png new file mode 100644 index 00000000..0bca4327 Binary files /dev/null and b/react-ui/src/assets/img/workspace-experiment.png differ diff --git a/react-ui/src/assets/img/workspace-intro-icon.png b/react-ui/src/assets/img/workspace-intro-icon.png new file mode 100644 index 00000000..d6d75417 Binary files /dev/null and b/react-ui/src/assets/img/workspace-intro-icon.png differ diff --git a/react-ui/src/assets/img/workspace-intro.png b/react-ui/src/assets/img/workspace-intro.png new file mode 100644 index 00000000..a8bc72ac Binary files /dev/null and b/react-ui/src/assets/img/workspace-intro.png differ diff --git a/react-ui/src/assets/img/workspace-pipeline.png b/react-ui/src/assets/img/workspace-pipeline.png new file mode 100644 index 00000000..f551cb9c Binary files /dev/null and b/react-ui/src/assets/img/workspace-pipeline.png differ diff --git a/react-ui/src/assets/img/workspace-quick-start.png b/react-ui/src/assets/img/workspace-quick-start.png new file mode 100644 index 00000000..b4e9f43c Binary files /dev/null and b/react-ui/src/assets/img/workspace-quick-start.png differ diff --git a/react-ui/src/assets/img/workspace-user.png b/react-ui/src/assets/img/workspace-user.png new file mode 100644 index 00000000..f64de254 Binary files /dev/null and b/react-ui/src/assets/img/workspace-user.png differ diff --git a/react-ui/src/components/BasicInfo/BasicInfoItem.tsx b/react-ui/src/components/BasicInfo/BasicInfoItem.tsx new file mode 100644 index 00000000..86e63891 --- /dev/null +++ b/react-ui/src/components/BasicInfo/BasicInfoItem.tsx @@ -0,0 +1,86 @@ +/* + * @Author: 赵伟 + * @Date: 2024-11-29 09:27:19 + * @Description: 用于 BasicInfo 和 BasicTableInfo 组件的子组件 + */ + +import { Typography } from 'antd'; +import React from 'react'; +import BasicInfoItemValue from './BasicInfoItemValue'; +import { type BasicInfoData, type BasicInfoLink } from './types'; + +type BasicInfoItemProps = { + /** 基础信息 */ + data: BasicInfoData; + /** 标题宽度 */ + labelWidth: number; + /** 自定义类名前缀 */ + classPrefix: string; + /** 标题是否显示省略号 */ + labelEllipsis?: boolean; + /** 标签对齐方式 */ + labelAlign?: 'start' | 'end' | 'justify'; +}; + +function BasicInfoItem({ + data, + labelWidth, + classPrefix, + labelEllipsis = true, + labelAlign = 'start', +}: BasicInfoItemProps) { + const { label, value, format, ellipsis } = data; + const formatValue = format ? format(value) : value; + const myClassName = `${classPrefix}__item`; + let valueComponent = undefined; + if (React.isValidElement(formatValue)) { + valueComponent =
{formatValue}
; + } else if (Array.isArray(formatValue)) { + valueComponent = ( +
+ {formatValue.map((item: BasicInfoLink) => ( + + ))} +
+ ); + } else if (typeof formatValue === 'object' && formatValue) { + valueComponent = ( + + ); + } else { + valueComponent = ( + + ); + } + return ( +
+
+ + {label} + +
+ {valueComponent} +
+ ); +} + +export default BasicInfoItem; diff --git a/react-ui/src/components/BasicInfo/BasicInfoItemValue.tsx b/react-ui/src/components/BasicInfo/BasicInfoItemValue.tsx new file mode 100644 index 00000000..c5a993e4 --- /dev/null +++ b/react-ui/src/components/BasicInfo/BasicInfoItemValue.tsx @@ -0,0 +1,58 @@ +/* + * @Author: 赵伟 + * @Date: 2024-11-29 09:27:19 + * @Description: 用于 BasicInfoItem 的组件 + */ + +import { isEmpty } from '@/utils'; +import { Link } from '@umijs/max'; +import { Typography } from 'antd'; + +type BasicInfoItemValueProps = { + /** 值是否显示省略号 */ + ellipsis?: boolean; + /** 自定义类名前缀 */ + classPrefix: string; + /** 值 */ + value?: string; + /** 内部链接 */ + link?: string; + /** 外部链接 */ + url?: string; +}; + +function BasicInfoItemValue({ + value, + link, + url, + classPrefix, + ellipsis = true, +}: BasicInfoItemValueProps) { + const myClassName = `${classPrefix}__item__value`; + let component = undefined; + if (url && value) { + component = ( + + {value} + + ); + } else if (link && value) { + component = ( + + {value} + + ); + } else { + component = {!isEmpty(value) ? value : '--'}; + } + + return ( +
+ + {component} + +
+ ); +} + +export default BasicInfoItemValue; diff --git a/react-ui/src/components/BasicInfo/index.less b/react-ui/src/components/BasicInfo/index.less new file mode 100644 index 00000000..ced2d2ba --- /dev/null +++ b/react-ui/src/components/BasicInfo/index.less @@ -0,0 +1,77 @@ +.kf-basic-info { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 20px 40px; + align-items: flex-start; + width: 80%; + + &__item { + display: flex; + align-items: flex-start; + width: calc(50% - 20px); + + &__label { + position: relative; + flex: none; + color: @text-color-secondary; + font-size: @font-size-content; + line-height: 1.6; + + &::after { + position: absolute; + content: ':'; + } + } + + &__value-container { + display: flex; + flex: 1; + flex-direction: column; + gap: 5px 0; + min-width: 0; + } + + &__value { + flex: 1; + min-width: 0; + margin-left: 16px; + font-size: @font-size-content; + line-height: 1.6; + word-break: break-all; + + &__text { + color: @text-color; + } + + &__link:hover { + text-decoration: underline @underline-color; + text-underline-offset: 3px; + } + } + + &__node { + flex: 1; + min-width: 0; + margin-left: 16px; + font-size: @font-size-content; + line-height: 1.6; + word-break: break-all; + } + } +} + +.kf-basic-info--three-columns { + width: 100%; + + .kf-basic-info__item { + width: calc((100% - 80px) / 3); + + &__label { + font-size: @font-size; + } + &__value { + font-size: @font-size; + } + } +} diff --git a/react-ui/src/components/BasicInfo/index.tsx b/react-ui/src/components/BasicInfo/index.tsx new file mode 100644 index 00000000..a918b8ee --- /dev/null +++ b/react-ui/src/components/BasicInfo/index.tsx @@ -0,0 +1,58 @@ +import classNames from 'classnames'; +import React from 'react'; +import BasicInfoItem from './BasicInfoItem'; +import './index.less'; +import type { BasicInfoData, BasicInfoLink } from './types'; +export type { BasicInfoData, BasicInfoLink }; + +export type BasicInfoProps = { + /** 基础信息 */ + datas: BasicInfoData[]; + /** 标题宽度 */ + labelWidth: number; + /** 标题是否显示省略号 */ + labelEllipsis?: boolean; + /** 是否一行三列 */ + threeColumns?: boolean; + /** 标签对齐方式 */ + labelAlign?: 'start' | 'end' | 'justify'; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +}; + +/** + * 基础信息展示组件,用于展示基础信息,支持一行两列或一行三列,支持数据格式化 + */ +export default function BasicInfo({ + datas, + labelWidth, + labelEllipsis = true, + labelAlign = 'start', + threeColumns = false, + className, + style, +}: BasicInfoProps) { + return ( +
+ {datas.map((item) => ( + + ))} +
+ ); +} diff --git a/react-ui/src/components/BasicInfo/types.ts b/react-ui/src/components/BasicInfo/types.ts new file mode 100644 index 00000000..be2ac774 --- /dev/null +++ b/react-ui/src/components/BasicInfo/types.ts @@ -0,0 +1,14 @@ +// 基础信息 +export type BasicInfoData = { + label: string; + value?: any; + ellipsis?: boolean; + format?: (_value?: any) => string | React.ReactNode | BasicInfoLink | BasicInfoLink[] | undefined; +}; + +// 值为链接的类型 +export type BasicInfoLink = { + value?: string; + link?: string; + url?: string; +}; diff --git a/react-ui/src/components/BasicTableInfo/index.less b/react-ui/src/components/BasicTableInfo/index.less new file mode 100644 index 00000000..1207d033 --- /dev/null +++ b/react-ui/src/components/BasicTableInfo/index.less @@ -0,0 +1,67 @@ +.kf-basic-table-info { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: stretch; + width: 100%; + border: 1px solid @border-color; + border-bottom: none; + border-radius: 4px; + + &__item { + display: flex; + align-items: stretch; + width: 25%; + border-bottom: 1px solid @border-color; + + &__label { + flex: none; + padding: 12px 20px; + color: @text-color-secondary; + font-size: 14px; + text-align: left; + background-color: .addAlpha(#606b7a, 0.05) []; + } + + &__value-container { + display: flex; + flex: 1; + flex-direction: column; + align-items: stretch; + min-width: 0; + } + + &__value { + flex: 1; + min-width: 0; + padding: 12px 20px 4px; + font-size: @font-size; + word-break: break-all; + + & + & { + padding-top: 0; + } + + &:last-child { + padding-bottom: 12px; + } + + &__text { + color: @text-color; + } + + &__link:hover { + text-decoration: underline @underline-color; + text-underline-offset: 3px; + } + } + + &__node { + flex: 1; + min-width: 0; + padding: 12px 20px; + font-size: @font-size; + word-break: break-all; + } + } +} diff --git a/react-ui/src/components/BasicTableInfo/index.tsx b/react-ui/src/components/BasicTableInfo/index.tsx new file mode 100644 index 00000000..357a82fb --- /dev/null +++ b/react-ui/src/components/BasicTableInfo/index.tsx @@ -0,0 +1,45 @@ +import classNames from 'classnames'; +import { BasicInfoProps } from '../BasicInfo'; +import BasicInfoItem from '../BasicInfo/BasicInfoItem'; +import { type BasicInfoData, type BasicInfoLink } from '../BasicInfo/types'; +import './index.less'; +export type { BasicInfoData, BasicInfoLink }; + +export type BasicTableInfoProps = Omit; + +/** + * 表格基础信息展示组件,用于展示基础信息,一行四列,支持数据格式化 + */ +export default function BasicTableInfo({ + datas, + labelWidth, + labelEllipsis, + className, + style, +}: BasicTableInfoProps) { + const remainder = datas.length % 4; + const array = []; + if (remainder > 0) { + for (let i = 0; i < 4 - remainder; i++) { + array.push({ + label: '', + value: false, // 用于区分是否是空数据,不能是空字符串、null、undefined + }); + } + } + const showDatas = [...datas, ...array]; + + return ( +
+ {showDatas.map((item, index) => ( + + ))} +
+ ); +} diff --git a/react-ui/src/components/CodeConfigItem/index.less b/react-ui/src/components/CodeConfigItem/index.less new file mode 100644 index 00000000..bc450f47 --- /dev/null +++ b/react-ui/src/components/CodeConfigItem/index.less @@ -0,0 +1,75 @@ +.code-config-item { + position: relative; + width: calc(33.33% - 7px); + padding: 15px; + background-color: .addAlpha(@primary-color, 0.04) []; + border: 1px solid transparent; + border-radius: 4px; + + &__checkbox { + flex: 1; + min-width: 0; + + :global { + .ant-checkbox + span { + flex: 1; + min-width: 0; + } + } + } + + &__name { + margin-right: 8px; + margin-bottom: 0 !important; + color: @text-color; + font-size: 14px; + } + + &__tag { + flex: none; + padding: 2px 11px; + font-size: 12px; + border-radius: 1000px; + + &--public { + color: @primary-color; + background-color: .addAlpha(@primary-color, 0.08) []; + border-color: .addAlpha(@primary-color, 0.5) []; + } + + &--private { + color: @warning-color; + background-color: .addAlpha(@warning-color, 0.08) []; + border-color: .addAlpha(@warning-color, 0.5) []; + } + } + + &__url { + height: 40px; + margin-bottom: 10px !important; + color: @text-color-secondary; + font-size: 13px; + cursor: pointer; + word-break: break-all; + } + + &__branch { + color: @text-color-tertiary; + font-size: 12px; + } + + &:hover { + background-color: .addAlpha(@primary-color, 0.08) []; + } + + &--active { + border-color: @primary-color; + box-shadow: 0px 0px 6px 1px rgba(0, 0, 0, 0.1); + } + + &--active &__name { + color: @primary-color; + } + + +} diff --git a/react-ui/src/components/CodeConfigItem/index.tsx b/react-ui/src/components/CodeConfigItem/index.tsx new file mode 100644 index 00000000..afe3e6ee --- /dev/null +++ b/react-ui/src/components/CodeConfigItem/index.tsx @@ -0,0 +1,81 @@ +import { type CodeConfigData } from '@/pages/CodeConfig/List'; +import { getGitUrl } from '@/utils'; +import { Checkbox, Flex, Typography } from 'antd'; +import { type CheckboxChangeEvent } from 'antd/es/checkbox'; +import classNames from 'classnames'; +import { useState } from 'react'; +import styles from './index.less'; + +type CodeConfigItemProps = { + item: CodeConfigData; + checked: boolean; + onChange?: (item: CodeConfigData, checked: boolean) => void; +}; + +function CodeConfigItem({ item, checked, onChange }: CodeConfigItemProps) { + const [isEllipsis, setIsEllipsis] = useState(false); + + const openProject = (e: React.MouseEvent) => { + e.stopPropagation(); + const { git_url, git_branch } = item; + const url = getGitUrl(git_url, git_branch); + window.open(url, '_blank'); + }; + + const handleChange = (e: CheckboxChangeEvent) => { + onChange?.(item, e.target.checked); + }; + + return ( +
+ + + + {item.code_repo_name} + + +
+ {item.is_public ? '公开' : '私有'} +
+
+ setIsEllipsis(ellipsis), // 必须这样,不然不能省略 + }} + onClick={openProject} + > + {item.git_url} + + + {item.git_branch} + +
+ ); +} + +export default CodeConfigItem; diff --git a/react-ui/src/components/CodeSelect/index.less b/react-ui/src/components/CodeSelect/index.less new file mode 100644 index 00000000..bf84e3b8 --- /dev/null +++ b/react-ui/src/components/CodeSelect/index.less @@ -0,0 +1,11 @@ +.kf-code-select { + position: relative; + display: flex; + align-items: center; + + &__button { + position: absolute; + top: 0; + left: calc(100% + 10px); + } +} diff --git a/react-ui/src/components/CodeSelect/index.tsx b/react-ui/src/components/CodeSelect/index.tsx new file mode 100644 index 00000000..2ea28ccb --- /dev/null +++ b/react-ui/src/components/CodeSelect/index.tsx @@ -0,0 +1,88 @@ +/* + * @Author: 赵伟 + * @Date: 2024-10-08 15:36:08 + * @Description: 流水线选择代码配置表单 + */ + +import CodeSelectorModal, { CodeConfigData } from '@/components/CodeSelectorModal'; +import KFIcon from '@/components/KFIcon'; +import { openAntdModal } from '@/utils/modal'; +import { Button } from 'antd'; +import classNames from 'classnames'; +import ParameterInput, { type ParameterInputProps } from '../ParameterInput'; +import './index.less'; + +export { + requiredValidator, + type ParameterInputObject, + type ParameterInputValue, +} from '../ParameterInput'; + +export interface CodeSelectProps extends ParameterInputProps { + value?: CodeConfigData; +} + +/** 代码配置选择表单组件 */ +function CodeSelect({ + value, + size, + disabled, + className, + style, + onChange, + ...rest +}: CodeSelectProps) { + // 选择代码配置 + const selectResource = () => { + const defaultSelected: CodeConfigData | undefined = + value && typeof value === 'object' ? value : undefined; + const { close } = openAntdModal(CodeSelectorModal, { + defaultSelected: defaultSelected, + onOk: (res) => { + if (res) { + const { code_repo_name } = res; + onChange?.({ + ...res, + value: code_repo_name, + showValue: code_repo_name, + fromSelect: true, + }); + } else { + onChange?.(undefined); + } + close(); + }, + }); + }; + + // 删除 + const handleRemove = () => { + onChange?.(undefined); + }; + + return ( +
+ + +
+ ); +} + +export default CodeSelect; diff --git a/react-ui/src/components/CodeSelectorModal/index.less b/react-ui/src/components/CodeSelectorModal/index.less new file mode 100644 index 00000000..eaff45d7 --- /dev/null +++ b/react-ui/src/components/CodeSelectorModal/index.less @@ -0,0 +1,50 @@ +.kf-code-selector-modal { + width: 100%; + height: 100%; + + &__search { + width: 100%; + } + + &__content { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 10px; + width: 100%; + max-height: 50vh; + margin-top: 24px; + margin-bottom: 30px; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: 10px; + } + + &__empty { + padding-top: 40px; + } + + // 覆盖 antd 样式 + .ant-input-affix-wrapper { + border-radius: 23px !important; + .ant-input-prefix { + margin-inline-end: 12px; + } + .ant-input-suffix { + margin-inline-end: 12px; + } + .ant-input-clear-icon { + font-size: 16px; + } + } + + .ant-input-group-addon { + display: none; + } + + .ant-pagination { + .ant-select-single { + height: 32px !important; + } + } +} diff --git a/react-ui/src/components/CodeSelectorModal/index.tsx b/react-ui/src/components/CodeSelectorModal/index.tsx new file mode 100644 index 00000000..afb68aec --- /dev/null +++ b/react-ui/src/components/CodeSelectorModal/index.tsx @@ -0,0 +1,195 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-11 16:31:18 + * @Description: 选择代码 + */ + +import KFIcon from '@/components/KFIcon'; +import KFModal from '@/components/KFModal'; +import { type CodeConfigData } from '@/pages/CodeConfig/List'; +import { getCodeConfigListReq, getCodeConfigPageNumReq } from '@/services/codeConfig'; +import { CustomPartial } from '@/types'; +import { to } from '@/utils/promise'; +import type { ModalProps, PaginationProps } from 'antd'; +import { Empty, Input, Pagination } from 'antd'; +import { useEffect, useState } from 'react'; +import CodeConfigItem from '../CodeConfigItem'; +import './index.less'; + +export { type CodeConfigData }; + +export type SelectCodeData = CustomPartial< + CodeConfigData, + | 'id' + | 'code_repo_name' + | 'git_url' + | 'git_branch' + | 'git_user_name' + | 'git_password' + | 'ssh_key' + | 'is_public' +>; + +export interface CodeSelectorModalProps extends Omit { + defaultSelected?: SelectCodeData; + onOk?: (params: SelectCodeData | undefined) => void; +} + +/** 选择代码配置的弹窗,推荐使用函数的方式打开 */ +function CodeSelectorModal({ defaultSelected, onOk, ...rest }: CodeSelectorModalProps) { + const DefaultPageSize = 18; + const [dataList, setDataList] = useState([]); + const [total, setTotal] = useState(0); + const [searchText, setSearchText] = useState(undefined); + const [inputText, setInputText] = useState(undefined); + const [selected, setSelected] = useState(defaultSelected); + const [isScrolled, setIsScrolled] = useState(false); + const [pagination, setPagination] = useState({ + current: defaultSelected?.id ? 0 : 1, // 为 0 时,不请求,等待接口返回选中的代码配置在第几页 + pageSize: DefaultPageSize, + }); + + useEffect(() => { + const getCodeConfigPageNum = async (id: number, size: number) => { + const [res] = await to( + getCodeConfigPageNumReq(id, { + size, + }), + ); + if (res) { + setPagination({ + current: typeof res.data === 'number' ? Math.max(0, res.data) + 1 : 1, + pageSize: DefaultPageSize, + }); + } else { + setPagination({ + current: 1, + pageSize: DefaultPageSize, + }); + } + }; + if (defaultSelected?.id) { + getCodeConfigPageNum(defaultSelected?.id, DefaultPageSize); + } + }, [defaultSelected?.id]); + + useEffect(() => { + // 获取数据请求 + const getDataList = async () => { + // 为 0 时,不请求,等待接口返回选中的代码配置在第几页 + if (pagination.current === 0) { + return; + } + const params = { + page: pagination.current! - 1, + size: pagination.pageSize, + code_repo_name: searchText || undefined, + }; + const [res] = await to(getCodeConfigListReq(params)); + if (res && res.data && res.data.content) { + setDataList(res.data.content); + setTotal(res.data.totalElements); + } + }; + + getDataList(); + }, [pagination, searchText]); + + useEffect(() => { + if (dataList.length > 0 && !isScrolled && defaultSelected?.id) { + const selectedItem = document.getElementById(`code-config-item-${defaultSelected?.id}`); + if (selectedItem) { + selectedItem.scrollIntoView(); + } + setIsScrolled(true); + } + }, [isScrolled, dataList, defaultSelected?.id]); + + // 搜索 + const handleSearch = (value: string) => { + setSearchText(value); + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + const handleChange = (item: CodeConfigData, checked: boolean) => { + if (checked) { + setSelected(item); + } else { + setSelected(undefined); + } + }; + + // 分页切换 + const handlePageChange: PaginationProps['onChange'] = (page, pageSize) => { + setPagination({ + current: page, + pageSize: pageSize, + }); + }; + + return ( + onOk?.(selected)} + destroyOnClose + > +
+ setInputText(e.target.value)} + suffix={null} + value={inputText} + prefix={ + + } + /> + {dataList?.length !== 0 ? ( + <> +
+ {dataList?.map((item) => ( + + ))} +
+ + + ) : ( +
+ +
+ )} +
+
+ ); +} + +export default CodeSelectorModal; diff --git a/react-ui/src/components/ConfigInfo/index.tsx b/react-ui/src/components/ConfigInfo/index.tsx new file mode 100644 index 00000000..0f81d46f --- /dev/null +++ b/react-ui/src/components/ConfigInfo/index.tsx @@ -0,0 +1,41 @@ +import BasicInfo, { type BasicInfoData, type BasicInfoProps } from '@/components/BasicInfo'; +import InfoGroup from '@/components/InfoGroup'; +import classNames from 'classnames'; +export type { BasicInfoData }; + +interface ConfigInfoProps extends BasicInfoProps { + /** 标题 */ + title: string; + /** 子元素 */ + children?: React.ReactNode; +} + +/** 详情基本信息块,目前主要用于主动机器学习、超参数寻优、自主学习详情中 */ +function ConfigInfo({ + title, + datas, + labelWidth, + labelAlign = 'start', + labelEllipsis = true, + threeColumns = true, + className, + style, + children, +}: ConfigInfoProps) { + return ( + +
+ + {children} +
+
+ ); +} + +export default ConfigInfo; diff --git a/react-ui/src/components/CopyingText/clipboard.js b/react-ui/src/components/CopyingText/clipboard.js new file mode 100644 index 00000000..1a27f01d --- /dev/null +++ b/react-ui/src/components/CopyingText/clipboard.js @@ -0,0 +1,12 @@ +import { message } from 'antd'; +import ClipboardJS from 'clipboard'; + +const clipboard = new ClipboardJS('#copying'); + +clipboard.on('success', () => { + message.success('复制成功'); +}); + +clipboard.on('error', () => { + message.error('复制失败'); +}); diff --git a/react-ui/src/components/CopyingText/index.less b/react-ui/src/components/CopyingText/index.less new file mode 100644 index 00000000..951b37dd --- /dev/null +++ b/react-ui/src/components/CopyingText/index.less @@ -0,0 +1,18 @@ +.copying-text { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + margin-left: 16px; + + &__text { + color: @text-color; + font-size: 15px; + } + + &__icon { + margin-left: 6px; + font-size: 14px; + cursor: pointer; + } +} diff --git a/react-ui/src/components/CopyingText/index.tsx b/react-ui/src/components/CopyingText/index.tsx new file mode 100644 index 00000000..5a87ebe3 --- /dev/null +++ b/react-ui/src/components/CopyingText/index.tsx @@ -0,0 +1,26 @@ +import KFIcon from '@/components/KFIcon'; +import { Tooltip } from 'antd'; +import styles from './index.less'; + +export type CopyingTextProps = { + text: string; +}; + +function CopyingText({ text }: CopyingTextProps) { + return ( +
+ {text} + + + +
+ ); +} + +export default CopyingText; diff --git a/react-ui/src/components/DictTag/index.tsx b/react-ui/src/components/DictTag/index.tsx new file mode 100644 index 00000000..e1a7aea0 --- /dev/null +++ b/react-ui/src/components/DictTag/index.tsx @@ -0,0 +1,111 @@ +import { ProSchemaValueEnumType } from '@ant-design/pro-components'; +import { Tag } from 'antd'; +import { DefaultOptionType } from 'antd/es/select'; +import React from 'react'; + +/* * + * + * @author whiteshader@163.com + * @datetime 2023/02/10 + * + * */ + +export interface DictValueEnumType extends ProSchemaValueEnumType { + id?: string | number; + key?: string | number; + value: string | number; + label: string; + listClass?: string; +} + +export interface DictOptionType extends DefaultOptionType { + id?: string | number; + key?: string | number; + text: string; + listClass?: string; +} + +export type DictValueEnumObj = Record; + +export type DictTagProps = { + key?: string; + value?: string | number; + enums?: DictValueEnumObj; + options?: DictOptionType[]; +}; + +const DictTag: React.FC = (props) => { + function getDictColor(type?: string) { + switch (type) { + case 'primary': + return 'blue'; + case 'success': + return 'success'; + case 'info': + return 'green'; + case 'warning': + return 'warning'; + case 'danger': + return 'error'; + case 'default': + default: + return 'default'; + } + } + + function getDictLabelByValue(value: string | number | undefined): string { + if (value === undefined) { + return ''; + } + if (props.enums) { + const item = props.enums[value]; + return item.label; + } + if (props.options) { + if (!Array.isArray(props.options)) { + // console.log('DictTag options is no array!'); + return ''; + } + for (const item of props.options) { + if (item.value === value) { + return item.text; + } + } + } + return String(props.value); + } + + function getDictListClassByValue(value: string | number | undefined): string { + if (value === undefined) { + return 'default'; + } + if (props.enums) { + const item = props.enums[value]; + return item.listClass || 'default'; + } + if (props.options) { + if (!Array.isArray(props.options)) { + // console.log('DictTag options is no array!'); + return 'default'; + } + for (const item of props.options) { + if (item.value === value) { + return item.listClass || 'default'; + } + } + } + return String(props.value); + } + + const getTagColor = () => { + return getDictColor(getDictListClassByValue(props.value).toLowerCase()); + }; + + const getTagText = (): string => { + return getDictLabelByValue(props.value); + }; + + return {getTagText()}; +}; + +export default DictTag; diff --git a/react-ui/src/components/ErrorBoundary/index.less b/react-ui/src/components/ErrorBoundary/index.less new file mode 100644 index 00000000..e69de29b diff --git a/react-ui/src/components/ErrorBoundary/index.tsx b/react-ui/src/components/ErrorBoundary/index.tsx new file mode 100644 index 00000000..2483c9e9 --- /dev/null +++ b/react-ui/src/components/ErrorBoundary/index.tsx @@ -0,0 +1,79 @@ +import KFEmpty, { EmptyType } from '@/components/KFEmpty'; +import { HomeUrl } from '@/utils/constant'; +import { Button } from 'antd'; +import { Component, ReactNode } from 'react'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; // Optional fallback UI to show in case of error +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + // Update state so the next render shows the fallback UI + return { hasError: true, error }; + } + + // componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // // You can log the error to an error reporting service here + // console.error('Error caught by ErrorBoundary:', error.message, errorInfo.componentStack); + // } + + render() { + if (this.state.hasError) { + return this.props.fallback || ; + } + return this.props.children; + } +} + +function ErrorBoundaryFallback({ error }: { error: Error | null }) { + const message = error && error instanceof Error ? error.message : 'Unknown error'; + const errorMsg = + process.env.NODE_ENV === 'development' ? message : '非常抱歉,程序运行错误,\n我们会尽快修复。'; + return ( + { + return ( + <> + + + + ); + }} + > + ); +} + +export default ErrorBoundary; diff --git a/react-ui/src/components/FormInfo/index.less b/react-ui/src/components/FormInfo/index.less new file mode 100644 index 00000000..322fb082 --- /dev/null +++ b/react-ui/src/components/FormInfo/index.less @@ -0,0 +1,19 @@ +.form-info { + min-height: 32px; + padding: 4px 11px; + color: @text-disabled-color; + font-size: @font-size-input; + background-color: rgba(0, 0, 0, 0.04); + border: 1px solid #d9d9d9; + border-radius: 6px; + + .ant-typography { + margin: 0 !important; + } +} + +.form-info--multiline { + .ant-typography { + white-space: pre-wrap; + } +} diff --git a/react-ui/src/components/FormInfo/index.tsx b/react-ui/src/components/FormInfo/index.tsx new file mode 100644 index 00000000..6416e6e5 --- /dev/null +++ b/react-ui/src/components/FormInfo/index.tsx @@ -0,0 +1,94 @@ +import { PipelineGlobalParamType, type PipelineGlobalParam } from '@/types'; +import { formatEnum } from '@/utils/format'; +import { Typography, type SelectProps } from 'antd'; +import classNames from 'classnames'; +import './index.less'; + +type FormInfoProps = { + /** 值 */ + value?: any; + /** 如果 `value` 是对象,取对象的哪个属性作为值 */ + valuePropName?: string; + /** 是否是多行文本 */ + textArea?: boolean; + /** 是否是下拉框 */ + select?: boolean; + /** 下拉框数据 */ + options?: SelectProps['options']; + /** 自定义节点 label、value 的字段 */ + fieldNames?: SelectProps['fieldNames']; + /** 全局参数 */ + globalParams?: PipelineGlobalParam[] | null; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +}; + +/** + * 模拟禁用的输入框,但是内容超长时,hover 时显示所有内容 + */ +function FormInfo({ + value, + valuePropName, + textArea = false, + select = false, + options, + fieldNames, + globalParams, + className, + style, +}: FormInfoProps) { + let showValue = value; + if (value && typeof value === 'object' && valuePropName) { + showValue = value[valuePropName]; + const reg = /^\$\{(.*)\}$/; + if (value.fromSelect && Array.isArray(globalParams) && globalParams.length > 0) { + const match = reg.exec(showValue); + if (match) { + const paramName = match[1]; + const foundParam = globalParams.find((v) => v.param_name === paramName); + if (foundParam) { + showValue = + foundParam.param_type === PipelineGlobalParamType.Boolean // 布尔类型转换 + ? foundParam.param_value + ? 'true' + : 'false' + : foundParam.param_value; + } + } + } + } else if (select === true && options) { + let _options: SelectProps['options'] = options; + if (fieldNames) { + _options = options.map((v) => { + return { + ...v, + label: fieldNames.label && v[fieldNames.label], + value: fieldNames.value && v[fieldNames.value], + options: fieldNames.options && v[fieldNames.options], + }; + }); + } + showValue = formatEnum(_options)(value); + } + + return ( +
+ + {showValue} + +
+ ); +} + +export default FormInfo; diff --git a/react-ui/src/components/FullScreenFrame/index.less b/react-ui/src/components/FullScreenFrame/index.less new file mode 100644 index 00000000..e7a68498 --- /dev/null +++ b/react-ui/src/components/FullScreenFrame/index.less @@ -0,0 +1,10 @@ +.kf-full-screen-frame { + width: 100%; + height: 100%; + + &__iframe { + width: 100%; + height: 100%; + border: none; + } +} diff --git a/react-ui/src/components/FullScreenFrame/index.tsx b/react-ui/src/components/FullScreenFrame/index.tsx new file mode 100644 index 00000000..800a727e --- /dev/null +++ b/react-ui/src/components/FullScreenFrame/index.tsx @@ -0,0 +1,35 @@ +import classNames from 'classnames'; +import './index.less'; + +type FullScreenFrameProps = { + /** URL */ + url: string; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; + /** 加载完成回调 */ + onLoad?: (e?: React.SyntheticEvent) => void; + /** 加载失败回调 */ + onError?: (e?: React.SyntheticEvent) => void; +}; + +/** + * 全屏 iframe,IFramePage 组件的子组件,开发中应该使用 IFramePage + */ +function FullScreenFrame({ url, className, style, onLoad, onError }: FullScreenFrameProps) { + return ( +
+ {url && ( + + )} +
+ ); +} + +export default FullScreenFrame; diff --git a/react-ui/src/components/HeaderDropdown/index.tsx b/react-ui/src/components/HeaderDropdown/index.tsx new file mode 100644 index 00000000..36153a73 --- /dev/null +++ b/react-ui/src/components/HeaderDropdown/index.tsx @@ -0,0 +1,24 @@ +import { useEmotionCss } from '@ant-design/use-emotion-css'; +import { Dropdown } from 'antd'; +import type { DropDownProps } from 'antd/es/dropdown'; +import classNames from 'classnames'; +import React from 'react'; + +export type HeaderDropdownProps = { + overlayClassName?: string; + placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topCenter' | 'topRight' | 'bottomCenter'; +} & Omit; + +const HeaderDropdown: React.FC = ({ overlayClassName: cls, ...restProps }) => { + const className = useEmotionCss(({ token }) => { + return { + [`@media screen and (max-width: ${token.screenXS})`]: { + width: '100%', + }, + }; + }); + + return ; +}; + +export default HeaderDropdown; diff --git a/react-ui/src/components/IFramePage/index.less b/react-ui/src/components/IFramePage/index.less new file mode 100644 index 00000000..48c07f94 --- /dev/null +++ b/react-ui/src/components/IFramePage/index.less @@ -0,0 +1,5 @@ +.kf-iframe-page { + position: relative; + width: 100%; + height: 100%; +} diff --git a/react-ui/src/components/IFramePage/index.tsx b/react-ui/src/components/IFramePage/index.tsx new file mode 100644 index 00000000..1982c816 --- /dev/null +++ b/react-ui/src/components/IFramePage/index.tsx @@ -0,0 +1,129 @@ +import FullScreenFrame from '@/components/FullScreenFrame'; +import { getKnowledgeGraphUrl, getLabelStudioUrl } from '@/services/developmentEnvironment'; +import Loading from '@/utils/loading'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import { FloatButton } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; +import './index.less'; + +export enum IframePageType { + DatasetAnnotation = 'DatasetAnnotation', // 数据标注 + AppDevelopment = 'AppDevelopment', // 应用开发 + DevEnv = 'DevEnv', // 开发环境 + GitLink = 'GitLink', // git link + Aim = 'Aim', // 实验对比 + Knowledge = 'Knowledge', // 知识图谱 + TensorBoard = 'TensorBoard', // 可视化结果 +} + +const getRequestAPI = (type: IframePageType): (() => Promise) => { + switch (type) { + case IframePageType.DatasetAnnotation: // 数据标注 + return getLabelStudioUrl; + case IframePageType.AppDevelopment: // 应用开发 + return () => Promise.resolve({ code: 200, data: 'http://172.20.32.197:30080/' }); + case IframePageType.DevEnv: // 开发环境 + return () => + Promise.resolve({ + code: 200, + data: SessionStorage.getItem(SessionStorage.editorUrlKey) || '', + }); + case IframePageType.GitLink: // git link + return () => Promise.resolve({ code: 200, data: 'http://172.20.32.201:4000' }); + case IframePageType.Aim: // Aim + return () => + Promise.resolve({ + code: 200, + data: SessionStorage.getItem(SessionStorage.aimUrlKey), + }); + case IframePageType.Knowledge: + // 知识图谱 + return getKnowledgeGraphUrl; + case IframePageType.TensorBoard: + // TensorBoard + return () => + Promise.resolve({ + code: 200, + data: SessionStorage.getItem(SessionStorage.tensorBoardUrlKey), + }); + } +}; + +type IframePageProps = { + /** 子系统 */ + type?: IframePageType; + /** url */ + url?: string; + /** 是否可以在页签上打开 */ + openInTab?: boolean; + /** 是否显示加载 */ + showLoading?: boolean; + /** 自定义样式类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +}; + +/** 系统内嵌 iframe,目前系统有数据标注、应用开发、开发环境、GitLink 四个子系统,使用时可以添加其他子系统 */ +function IframePage({ + type, + url, + showLoading = true, + openInTab = false, + className, + style, +}: IframePageProps) { + const [iframeUrl, setIframeUrl] = useState(''); + // const [loading, setLoading] = useState(false); + + useEffect(() => { + const requestIframeUrl = async (type: IframePageType) => { + if (showLoading) { + Loading.show(); + } + const [res] = await to(getRequestAPI(type)()); + if (res && res.data) { + setIframeUrl(res.data); + } else { + if (showLoading) { + Loading.hide(); + } + } + }; + + if (type) { + requestIframeUrl(type); + } else if (url) { + if (showLoading) { + Loading.show(); + } + + setIframeUrl(url); + } + }, [type, url, showLoading]); + + const handleLoad = () => { + if (showLoading) { + Loading.hide(); + } + }; + + const handleError = (error?: React.SyntheticEvent) => { + console.log('error', error); + if (showLoading) { + Loading.hide(); + } + }; + + return ( +
+ {/* {loading && createPortal(, document.body)} */} + {iframeUrl && } + {openInTab && window.open(iframeUrl, '_blank')} />} +
+ ); +} + +export default IframePage; diff --git a/react-ui/src/components/InfoGroup/InfoGroupTitle.less b/react-ui/src/components/InfoGroup/InfoGroupTitle.less new file mode 100644 index 00000000..b8aff97c --- /dev/null +++ b/react-ui/src/components/InfoGroup/InfoGroupTitle.less @@ -0,0 +1,40 @@ +.kf-info-group-title { + width: 100%; + height: 56px; + padding: 0 @content-padding; + background: linear-gradient( + 179.03deg, + rgba(199, 223, 255, 0.12) 0%, + rgba(22, 100, 255, 0.04) 100% + ); + border: 1px solid #e8effb; + border-radius: 4px 4px 0 0; + + &__image { + width: 16px; + height: 16px; + margin-right: 10px; + } + + &__text { + position: relative; + color: @text-color; + font-weight: 500; + font-size: @font-size-title; + .singleLine(); + + &::after { + position: absolute; + bottom: 6px; + left: 0; + width: 100%; + height: 6px; + background: linear-gradient( + to right, + .addAlpha(@primary-color, 0.4) [] 0, + .addAlpha(@primary-color, 0) [] 100% + ); + content: ''; + } + } +} diff --git a/react-ui/src/components/InfoGroup/InfoGroupTitle.tsx b/react-ui/src/components/InfoGroup/InfoGroupTitle.tsx new file mode 100644 index 00000000..bde57b3f --- /dev/null +++ b/react-ui/src/components/InfoGroup/InfoGroupTitle.tsx @@ -0,0 +1,31 @@ +import { Flex } from 'antd'; +import classNames from 'classnames'; +import './InfoGroupTitle.less'; + +type InfoGroupTitleProps = { + /** 标题 */ + title: string; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +}; + +/** + * 信息组标题 + */ +function InfoGroupTitle({ title, style, className }: InfoGroupTitleProps) { + return ( + + + {title} + + ); +} + +export default InfoGroupTitle; diff --git a/react-ui/src/components/InfoGroup/index.less b/react-ui/src/components/InfoGroup/index.less new file mode 100644 index 00000000..94c56187 --- /dev/null +++ b/react-ui/src/components/InfoGroup/index.less @@ -0,0 +1,11 @@ +.kf-info-group { + width: 100%; + + &__content { + padding: 20px @content-padding; + background-color: white; + border: 1px solid @border-color; + border-top: none; + border-radius: 0 0 4px 4px; + } +} diff --git a/react-ui/src/components/InfoGroup/index.tsx b/react-ui/src/components/InfoGroup/index.tsx new file mode 100644 index 00000000..0f2a3b42 --- /dev/null +++ b/react-ui/src/components/InfoGroup/index.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import InfoGroupTitle from './InfoGroupTitle'; +import './index.less'; + +type InfoGroupProps = { + /** 标题 */ + title: string; + /** 高度, 如果要纵向滚动,需要设置高度 */ + height?: string | number; + /** 宽度, 如果要横向滚动,需要设置宽度 */ + width?: string | number; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; + /** 子元素 */ + children?: React.ReactNode; +}; + +/** + * 信息组,用于展示基本信息,支持横向、纵向滚动。自动机器学习、超参数寻优都是使用这个组件 + */ +function InfoGroup({ title, height, width, className, style, children }: InfoGroupProps) { + const contentStyle: React.CSSProperties = {}; + if (height) { + contentStyle.height = height; + contentStyle.overflowY = 'auto'; + } + if (width) { + contentStyle.width = width; + contentStyle.overflowX = 'auto'; + } + return ( +
+ +
+ {children} +
+
+ ); +} + +export default InfoGroup; diff --git a/react-ui/src/components/KFBreadcrumb/index.tsx b/react-ui/src/components/KFBreadcrumb/index.tsx new file mode 100644 index 00000000..dc87efc2 --- /dev/null +++ b/react-ui/src/components/KFBreadcrumb/index.tsx @@ -0,0 +1,57 @@ +/* + * @Author: 赵伟 + * @Date: 2024-09-02 08:42:57 + * @Description: 自定义面包屑,暂时不用,使用了 ProBreadcrumb + */ + +import { Breadcrumb, type BreadcrumbProps } from 'antd'; +import { Link, matchPath, useLocation } from 'umi'; +// import routes from '../../../config/config'; // 导入你的路由配置 +type Route = { + path: string; + breadcrumb?: string; + routes?: Route[]; + redirect?: string; + name?: string; + component?: string; + layout?: boolean; + key?: string; +}; + +const routes: Route[] = []; + +const KFBreadcrumb = () => { + const location = useLocation(); + + const items: BreadcrumbProps['items'] = []; + + // 遍历路由表,生成面包屑数据 + const generateBreadcrumbs = (pathname: string, routes: Route[], prefix: string = '') => { + for (const route of routes) { + if (route.redirect || route.layout === false || !route.path || route.path === '*') { + continue; + } + const match = matchPath( + { path: `${prefix}/${route.path}`, end: route.routes ? false : true }, + pathname, + ); + if (match) { + items.push({ + path: route.path.startsWith('/') ? route.path : `${prefix}/${route.path}`, + title: {route.breadcrumb}, + }); + } + if (route.routes) { + generateBreadcrumbs(pathname, route.routes, `${prefix}/${route.path}`); + } + } + }; + + generateBreadcrumbs(location.pathname, routes); + + // const itemRender = (route, params, routes, paths) => {}; + + return ; +}; + +export default KFBreadcrumb; diff --git a/react-ui/src/components/KFButton/index.less b/react-ui/src/components/KFButton/index.less new file mode 100644 index 00000000..36a74d2c --- /dev/null +++ b/react-ui/src/components/KFButton/index.less @@ -0,0 +1,25 @@ +body { + .kf-primary-button.ant-btn-color-primary.ant-btn-variant-link { + background-color: .addAlpha(@primary-color, 0.07) [] !important; + } + + .kf-default-button.ant-btn-color-default.ant-btn-variant-link { + background-color: .addAlpha(@text-color-secondary, 0.07) [] !important; + } + + .kf-danger-button.ant-btn-color-danger.ant-btn-variant-link { + background-color: .addAlpha(@error-color, 0.07) [] !important; + } + + .ant-btn-color-default.ant-btn-variant-link.kf-default-button:not(:disabled):not( + .ant-btn-disabled + ):hover { + color: .addAlpha(@text-color-secondary, 0.5) []; + } + + .ant-btn-color-default.ant-btn-variant-link.kf-default-button:not(:disabled):not( + .ant-btn-disabled + ):active { + color: @text-color-secondary; + } +} diff --git a/react-ui/src/components/KFButton/index.tsx b/react-ui/src/components/KFButton/index.tsx new file mode 100644 index 00000000..f28bbcc2 --- /dev/null +++ b/react-ui/src/components/KFButton/index.tsx @@ -0,0 +1,75 @@ +import themes from '@/styles/theme.less'; +import { addAlpha, derivePrimaryStates } from '@/utils/color'; +import { Button, ButtonProps } from 'antd'; +import { createStyles } from 'antd-style'; +import './index.less'; + +type KFColor = 'primary' | 'default' | 'danger'; + +export interface KFButtonProps extends ButtonProps { + kfColor?: KFColor; +} + +const useStyles = createStyles(({ token, css }) => ({ + primary: css` + color: ${token.colorPrimary} !important; + background-color: ${addAlpha(themes['primaryColor'], 0.07)} !important; + + &:hover { + color: ${token.colorPrimaryHover} !important; + } + + &:active { + color: ${token.colorPrimaryActive} !important; + } + `, + default: css` + color: ${themes['textColorSecondary']} !important; + background-color: ${addAlpha(themes['textColorSecondary'], 0.07)} !important; + + &:hover { + color: ${derivePrimaryStates(themes['textColorSecondary']).colorPrimaryHover} !important; + } + + &:active { + color: ${derivePrimaryStates(themes['textColorSecondary']).colorPrimaryActive} !important; + } + `, + danger: css` + color: ${themes['errorColor']} !important; + background-color: ${addAlpha(themes['errorColor'], 0.07)} !important; + + &:hover { + color: ${derivePrimaryStates(themes['errorColor']).colorPrimaryHover} !important; + } + + &:active { + color: ${derivePrimaryStates(themes['errorColor']).colorPrimaryActive} !important; + } + `, +})); + +function KFButton({ kfColor = 'default', className, ...rest }: KFButtonProps) { + const { styles, cx } = useStyles(); + + let style = ''; + switch (kfColor) { + case 'primary': + style = styles.primary; + break; + case 'default': + style = styles.default; + break; + case 'danger': + style = styles.danger; + break; + default: + break; + } + + return ( + + ); +} + +export default KFButton; diff --git a/react-ui/src/components/KFEmpty/index.less b/react-ui/src/components/KFEmpty/index.less new file mode 100644 index 00000000..3e5a85b4 --- /dev/null +++ b/react-ui/src/components/KFEmpty/index.less @@ -0,0 +1,40 @@ +.kf-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + + &__image { + width: 475px; + } + + &__title { + margin-top: 15px; + color: @text-color; + font-weight: 500; + font-size: 30px; + text-align: center; + } + + &__content { + max-width: 50%; + margin-top: 15px; + color: @text-color-secondary; + font-size: 15px; + white-space: pre-line; + text-align: center; + } + + &__footer { + display: flex; + align-items: center; + justify-content: center; + margin-top: 20px; + margin-bottom: 30px; + + &__button { + height: 32px; + } + } +} diff --git a/react-ui/src/components/KFEmpty/index.tsx b/react-ui/src/components/KFEmpty/index.tsx new file mode 100644 index 00000000..b2a680f6 --- /dev/null +++ b/react-ui/src/components/KFEmpty/index.tsx @@ -0,0 +1,77 @@ +import { Button } from 'antd'; +import classNames from 'classnames'; +import './index.less'; + +export enum EmptyType { + NoData = 'NoData', + NotFound = 'NotFound', + Developing = 'Developing', +} + +type EmptyProps = { + /** 类型 */ + type: EmptyType; + /** 标题 */ + title?: string; + /** 内容 */ + content?: string; + /** 是否有页脚,如果有默认是一个按钮 */ + hasFooter?: boolean; + /** 按钮标题,默认是"刷新" */ + buttonTitle?: string; + /** 按钮点击回调 */ + onButtonClick?: () => void; + /** 自定义页脚内容 */ + footer?: () => React.ReactNode; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +}; + +function getEmptyImage(type: EmptyType) { + switch (type) { + case EmptyType.NoData: + return require('@/assets/img/no-data.png'); + case EmptyType.NotFound: + return require('@/assets/img/404.png'); + case EmptyType.Developing: + return require('@/assets/img/missing-back.png'); + } +} + +/** 空状态 */ +function KFEmpty({ + className, + style, + type, + title, + content, + hasFooter = true, + footer, + buttonTitle = '刷新', + onButtonClick, +}: EmptyProps) { + const image = getEmptyImage(type); + + return ( +
+ +
{title}
+
{content}
+ {hasFooter && ( +
+ {footer ? ( + footer() + ) : ( + + )} +
+ )} +
+ ); +} + +export default KFEmpty; diff --git a/react-ui/src/components/KFIcon/index.tsx b/react-ui/src/components/KFIcon/index.tsx new file mode 100644 index 00000000..38d3644c --- /dev/null +++ b/react-ui/src/components/KFIcon/index.tsx @@ -0,0 +1,39 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-17 12:53:06 + * @Description: 封装 iconfont 组件 + */ +import '@/iconfont/iconfont-menu.js'; +import '@/iconfont/iconfont.js'; +import { createFromIconfontCN } from '@ant-design/icons'; + +const Icon = createFromIconfontCN({ + scriptUrl: '', +}); + +type IconFontProps = Parameters[0]; + +interface KFIconProps extends IconFontProps { + /** 图标 */ + type: string; + /** 字体大小 */ + font?: number; + /** 字体颜色 */ + color?: string; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +} + +/** 封装 iconfont 图标 */ +function KFIcon({ type, font = 15, color, className, style, ...rest }: KFIconProps) { + const iconStyle = { + ...style, + fontSize: font, + color, + }; + return ; +} + +export default KFIcon; diff --git a/react-ui/src/components/KFModal/KFModalTitle.less b/react-ui/src/components/KFModal/KFModalTitle.less new file mode 100644 index 00000000..60e2cbde --- /dev/null +++ b/react-ui/src/components/KFModal/KFModalTitle.less @@ -0,0 +1,12 @@ +.kf-modal-title { + display: flex; + align-items: center; + color: @primary-color; + font-weight: 400; + font-size: 20px; + + &__image { + width: 22px; + margin-right: 10px; + } +} diff --git a/react-ui/src/components/KFModal/KFModalTitle.tsx b/react-ui/src/components/KFModal/KFModalTitle.tsx new file mode 100644 index 00000000..d2ec3265 --- /dev/null +++ b/react-ui/src/components/KFModal/KFModalTitle.tsx @@ -0,0 +1,31 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-28 14:18:11 + * @Description: 自定义 Modal Title + */ + +import classNames from 'classnames'; +import React from 'react'; +import './KFModalTitle.less'; + +type ModalTitleProps = { + /** 标题 */ + title: React.ReactNode; + /** 图片 */ + image?: string; + /** 自定义样式 */ + style?: React.CSSProperties; + /** 自定义类名 */ + className?: string; +}; + +function ModalTitle({ title, image, style, className }: ModalTitleProps) { + return ( +
+ {image && } + {title} +
+ ); +} + +export default ModalTitle; diff --git a/react-ui/src/components/KFModal/index.less b/react-ui/src/components/KFModal/index.less new file mode 100644 index 00000000..fafc6f7d --- /dev/null +++ b/react-ui/src/components/KFModal/index.less @@ -0,0 +1,32 @@ +.kf-modal { + .ant-modal-content { + padding: 40px 67px; + background-image: url(@/assets/img/modal-back.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100%; + border-radius: 20px; + } + .ant-modal-header { + margin-bottom: 30px; + background-color: transparent; + } + .ant-modal-footer { + display: flex; + justify-content: center; + margin-top: 40px; + + .ant-btn { + height: 40px; + padding: 0 30px; + font-size: @font-size-content; + border-radius: 6px; + } + .ant-btn-default { + border-color: transparent; + } + .ant-btn + .ant-btn { + margin-left: 20px; + } + } +} diff --git a/react-ui/src/components/KFModal/index.tsx b/react-ui/src/components/KFModal/index.tsx new file mode 100644 index 00000000..8156d6a9 --- /dev/null +++ b/react-ui/src/components/KFModal/index.tsx @@ -0,0 +1,40 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-15 10:01:29 + * @Description: 自定义 Modal + */ + +import { Modal, type ModalProps } from 'antd'; +import classNames from 'classnames'; +import KFModalTitle from './KFModalTitle'; +import './index.less'; + +export interface KFModalProps extends ModalProps { + /** 标题图片 */ + image?: string; +} + +/** 自定义 Modal,应用中的业务 Modal 应该使用它进行封装,推荐使用函数的方式打开 */ +function KFModal({ + title, + image, + children, + className, + centered, + maskClosable, + ...rest +}: KFModalProps) { + return ( + } + > + {children} + + ); +} + +export default KFModal; diff --git a/react-ui/src/components/KFRadio/index.less b/react-ui/src/components/KFRadio/index.less new file mode 100644 index 00000000..455492a4 --- /dev/null +++ b/react-ui/src/components/KFRadio/index.less @@ -0,0 +1,37 @@ +.kf-radio { + display: flex; + align-items: center; + + &__item { + display: flex; + align-items: center; + padding: 12px 20px; + color: @text-color-secondary; + border: 1px solid #e0e0e0; + border-radius: 8px; + + &:hover { + color: @primary-color-hover; + border: 1px solid @primary-color-hover; + } + + &:active { + color: @primary-color; + border: 1px solid @primary-color; + } + + &--active { + color: @primary-color; + border: 1px solid @primary-color; + + &:hover { + color: @primary-color; + border: 1px solid @primary-color; + } + } + + & + & { + margin-left: 20px; + } + } +} diff --git a/react-ui/src/components/KFRadio/index.tsx b/react-ui/src/components/KFRadio/index.tsx new file mode 100644 index 00000000..7191dae6 --- /dev/null +++ b/react-ui/src/components/KFRadio/index.tsx @@ -0,0 +1,55 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-17 16:59:42 + * @Description: 自定义 Radio + */ + +import classNames from 'classnames'; +import './index.less'; + +export type KFRadioItem = { + title: string; + value: string; + icon?: React.ReactNode; +}; + +type KFRadioProps = { + /** 选项 */ + items: KFRadioItem[]; + /** 当前选中项 */ + value?: string; + /** 自定义样式 */ + style?: React.CSSProperties; + /** 自定义类名 */ + className?: string; + /** 选中回调 */ + onChange?: (value: string) => void; +}; + +/** + * 自定义 Radio + */ +function KFRadio({ items, value, style, className, onChange }: KFRadioProps) { + return ( + + {items.map((item) => { + return ( + onChange?.(item.value)} + > + {item.icon} + {item.title} + + ); + })} + + ); +} + +export default KFRadio; diff --git a/react-ui/src/components/KFSpin/index.less b/react-ui/src/components/KFSpin/index.less new file mode 100644 index 00000000..7d532d2d --- /dev/null +++ b/react-ui/src/components/KFSpin/index.less @@ -0,0 +1,19 @@ +.kf-spin { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1001; // 设置大于 Modal 的 z-index + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: rgba(255, 255, 255, 0.3); + + &__label { + margin-top: 20px; + color: @text-color; + font-size: @font-size-content; + } +} diff --git a/react-ui/src/components/KFSpin/index.tsx b/react-ui/src/components/KFSpin/index.tsx new file mode 100644 index 00000000..ee054aea --- /dev/null +++ b/react-ui/src/components/KFSpin/index.tsx @@ -0,0 +1,25 @@ +/* + * @Author: 赵伟 + * @Date: 2024-09-02 08:42:57 + * @Description: 自定义 Spin + */ + +import { Spin, SpinProps } from 'antd'; +import './index.less'; + +interface KFSpinProps extends SpinProps { + /** 加载文本 */ + label?: string; +} + +/** 自定义 Spin */ +function KFSpin({ label = '加载中', ...rest }: KFSpinProps) { + return ( +
+ +
{label}
+
+ ); +} + +export default KFSpin; diff --git a/react-ui/src/components/MenuIconSelector/index.less b/react-ui/src/components/MenuIconSelector/index.less new file mode 100644 index 00000000..5a64a8d3 --- /dev/null +++ b/react-ui/src/components/MenuIconSelector/index.less @@ -0,0 +1,40 @@ +.menu-icon-selector { + display: grid; + grid-template-columns: repeat(4, 80px); + gap: 20px; + justify-content: space-between; + width: 100%; + + &__item { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + + &:hover { + border-color: @primary-color; + } + + &__icon { + display: block; + } + + &__icon--active { + display: none; + } + + &:hover &__icon, + &:active &__icon { + display: none; + } + + &:hover &__icon--active, + &:active &__icon--active { + display: block; + } + } +} diff --git a/react-ui/src/components/MenuIconSelector/index.tsx b/react-ui/src/components/MenuIconSelector/index.tsx new file mode 100644 index 00000000..fa38910b --- /dev/null +++ b/react-ui/src/components/MenuIconSelector/index.tsx @@ -0,0 +1,71 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-17 16:59:42 + * @Description: 菜单图标选择器 + */ + +import KFIcon from '@/components/KFIcon'; +import KFModal from '@/components/KFModal'; +import iconData from '@/iconfont/iconfont-menu.json'; +import { type ModalProps } from 'antd'; +import { useEffect, useState } from 'react'; +import styles from './index.less'; + +interface MenuIconSelectorProps extends Omit { + /** 选中的图标 */ + selectedIcon?: string; + /** 选择回调 */ + onOk: (param: string) => void; +} + +type IconObject = { + icon_id: string; + font_class: string; +}; + +/** 菜单图标选择器 */ +function MenuIconSelector({ open, selectedIcon, onOk, ...rest }: MenuIconSelectorProps) { + const [icons, setIcons] = useState([]); + useEffect(() => { + const glyphs = iconData.glyphs as IconObject[]; + setIcons(glyphs.filter((item) => !item.font_class.endsWith('-active'))); + }, []); + + return ( + +
+ {icons.map((icon) => ( +
onOk(icon.font_class)} + > + + +
+ ))} +
+
+ ); +} + +export default MenuIconSelector; diff --git a/react-ui/src/components/MessageBroadcast/index.less b/react-ui/src/components/MessageBroadcast/index.less new file mode 100644 index 00000000..1935c56b --- /dev/null +++ b/react-ui/src/components/MessageBroadcast/index.less @@ -0,0 +1,18 @@ +.message-broadcast { + position: relative; + width: 32px; + height: 32px; + cursor: pointer; + .backgroundFullImage(url(@/assets/img/message/trumpet.png)); + + &:hover { + background-image: url(@/assets/img/message/trumpet-hover.png); + } + + &__red-point { + position: absolute; + top: 8px; + left: 18px; + width: 6px; + } +} diff --git a/react-ui/src/components/MessageBroadcast/index.tsx b/react-ui/src/components/MessageBroadcast/index.tsx new file mode 100644 index 00000000..71d03d53 --- /dev/null +++ b/react-ui/src/components/MessageBroadcast/index.tsx @@ -0,0 +1,47 @@ +import RedPointImg from '@/assets/img/message/red-point.png'; +import { getMessageCountReq } from '@/services/message'; +import { to } from '@/utils/promise'; +import { useModel, useNavigate } from '@umijs/max'; +import { useCallback, useEffect, useState } from 'react'; +import styles from './index.less'; + +function MessageBroadcast() { + const { initialState } = useModel('@@initialState'); + const { currentUser } = initialState || {}; + const { userId } = currentUser || {}; + const [total, setTotal] = useState(0); + const navigate = useNavigate(); + + const getMessageCount = useCallback(async () => { + if (!userId) return; + const params: Record = { + receiver: userId, + type: -1, + }; + const [res] = await to(getMessageCountReq(params)); + if (res && res.data) { + const { unread_total } = res.data; + setTotal(unread_total); + } + }, [userId]); + + useEffect(() => { + const interval = setInterval(() => { + getMessageCount(); + }, 60 * 1000); + getMessageCount(); + return () => { + clearInterval(interval); + }; + }, [getMessageCount]); + + return ( +
navigate('/workspace/message')}> + {total > 0 && ( + + )} +
+ ); +} + +export default MessageBroadcast; diff --git a/react-ui/src/components/PageContainer/index.less b/react-ui/src/components/PageContainer/index.less new file mode 100644 index 00000000..6f055f71 --- /dev/null +++ b/react-ui/src/components/PageContainer/index.less @@ -0,0 +1,14 @@ +.kf-page-container { + height: 100%; + + &__breadcrumb { + display: flex; + align-items: center; + height: 70px; + padding-left: 30px; + } + + &__content { + height: calc(100% - 70px); + } +} diff --git a/react-ui/src/components/PageContainer/index.tsx b/react-ui/src/components/PageContainer/index.tsx new file mode 100644 index 00000000..c8a35416 --- /dev/null +++ b/react-ui/src/components/PageContainer/index.tsx @@ -0,0 +1,16 @@ +import { ProBreadcrumb } from '@ant-design/pro-components'; +import React from 'react'; +import './index.less'; + +function PageContainer({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+
{children}
+
+ ); +} + +export default PageContainer; diff --git a/react-ui/src/components/PageTitle/index.less b/react-ui/src/components/PageTitle/index.less new file mode 100644 index 00000000..40913e00 --- /dev/null +++ b/react-ui/src/components/PageTitle/index.less @@ -0,0 +1,31 @@ +.kf-page-title { + display: flex; + align-items: center; + height: 50px; + padding-left: 30px; + background-image: url(@/assets/img/page-title-bg.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + + &__tips { + position: relative; + margin-left: 18px; + padding: 3px 15px; + color: @primary-color; + background: .addAlpha(@primary-color, 0.1) []; + border-radius: 4px; + + &::before { + position: absolute; + top: 10px; + left: -6px; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-right: 6px solid .addAlpha(@primary-color, 0.1) []; + border-bottom: 4px solid transparent; + content: ''; + } + } +} diff --git a/react-ui/src/components/PageTitle/index.tsx b/react-ui/src/components/PageTitle/index.tsx new file mode 100644 index 00000000..113f9b7b --- /dev/null +++ b/react-ui/src/components/PageTitle/index.tsx @@ -0,0 +1,41 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-17 14:01:46 + * @Description: 页面标题 + */ +import KFIcon from '@/components/KFIcon'; +import classNames from 'classnames'; +import React from 'react'; +import './index.less'; + +type PageTitleProps = { + /** 标题 */ + title: React.ReactNode; + /** 图标 */ + tooltip?: string; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; + /** 自定义标题 */ + titleRender?: () => React.ReactNode; +}; + +/** + * 页面标题 + */ +function PageTitle({ title, style, className, tooltip }: PageTitleProps) { + return ( +
+
{title}
+ {tooltip && ( +
+ + {tooltip} +
+ )} +
+ ); +} + +export default PageTitle; diff --git a/react-ui/src/components/ParameterInput/index.less b/react-ui/src/components/ParameterInput/index.less new file mode 100644 index 00000000..ff0a21f7 --- /dev/null +++ b/react-ui/src/components/ParameterInput/index.less @@ -0,0 +1,82 @@ +.parameter-input { + width: 100%; + min-width: 0; + padding: 4px 11px; + border: 1px solid #d9d9d9; + border-radius: 6px; + cursor: pointer; + + &:hover { + border-color: @primary-color; + } + + &__content { + display: flex; + align-items: center; + width: fit-content; + max-width: 100%; + min-height: 22px; + padding: 0 8px; + color: .addAlpha(@text-color, 0.8) []; + background-color: rgba(0, 0, 0, 0.06); + border-radius: 4px; + + &__value { + //.singleLine(); + margin-right: 8px; + font-size: @font-size-input; + line-height: 1.5714285714285714; + } + + &__close-icon { + font-size: 10px; + + &:hover { + color: #000; + } + } + } + + &__placeholder { + min-height: 22px; + color: @text-placeholder-color; + font-size: @font-size-input; + line-height: 1.5714285714285714; + } +} + +.parameter-input.parameter-input--large { + padding: 10px 11px; + font-size: @font-size-input-lg; + + .parameter-input__placeholder, + .parameter-input__content__value { + min-height: 24px; + font-size: @font-size-input-lg; + line-height: 1.5; + } + + .parameter-input__content__close-icon { + font-size: 12px; + } +} + +.parameter-input.parameter-input--small { + padding: 0 7px; + font-size: @font-size-input; + + .parameter-input__placeholder, + .parameter-input__content__value { + min-height: 22px; + font-size: @font-size-input; + line-height: 1.5714285714285714; + } + + .parameter-input__content__close-icon { + font-size: 10px; + } +} + +.parameter-input.parameter-input--error { + border-color: @error-color; +} diff --git a/react-ui/src/components/ParameterInput/index.tsx b/react-ui/src/components/ParameterInput/index.tsx new file mode 100644 index 00000000..fef6b900 --- /dev/null +++ b/react-ui/src/components/ParameterInput/index.tsx @@ -0,0 +1,175 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 08:42:57 + * @Description: 参数输入表单组件,支持手动输入,也支持选择全局参数 + */ + +import { CommonTabKeys } from '@/enums'; +import { CloseOutlined } from '@ant-design/icons'; +import { ConfigProvider, Form, Input, Typography } from 'antd'; +import { RuleObject } from 'antd/es/form'; +import classNames from 'classnames'; +import { ReactNode } from 'react'; +import './index.less'; + +// 如果值是对象时的类型 +export type ParameterInputObject = { + value?: any; // 值 + showValue?: any; // 显示值 + fromSelect?: boolean; // 是否来自选择 + activeTab?: CommonTabKeys; // 选择镜像、数据集、模型时,保存当前激活的tab + expandedKeys?: string[]; // 选择镜像、数据集、模型时,保存展开的keys + checkedKeys?: string[]; // 选择镜像、数据集、模型时,保存选中的keys + [key: string]: any; +}; + +// 值类型 +export type ParameterInputValue = ParameterInputObject | string; + +export interface ParameterInputProps { + /** 值,可以是字符串,也可以是 ParameterInputObject 对象 */ + value?: ParameterInputValue; + /** + * 值变化时的回调 + * @param value 值,可以是字符串,也可以是 ParameterInputObject 对象 + */ + onChange?: (value?: ParameterInputValue) => void; + /** 点击时的回调 */ + onClick?: () => void; + /** 删除时的回调 */ + onRemove?: () => void; + /** 是否可以手动输入 */ + canInput?: boolean; + /** 是否是文本框 */ + textArea?: boolean; + /** 占位符 */ + placeholder?: string; + /** 是否允许清除 */ + allowClear?: boolean; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; + /** 大小 */ + size?: 'middle' | 'small' | 'large'; + /** 是否禁用 */ + disabled?: boolean; + /** 元素 id */ + id?: string; + /** 带标签的 input,设置后置标签 */ + addonAfter?: ReactNode; +} + +function ParameterInput({ + value, + onChange, + onClick, + onRemove, + canInput = true, + textArea = false, + allowClear, + className, + style, + size, + disabled = false, + id, + ...rest +}: ParameterInputProps) { + const valueObj = + typeof value === 'string' ? { value: value, fromSelect: false, showValue: value } : value; + if (valueObj && !valueObj.showValue) { + valueObj.showValue = typeof valueObj.value === 'string' ? valueObj.value : ''; + } + const isSelect = valueObj?.fromSelect; + const placeholder = valueObj?.placeholder || rest?.placeholder; + const InputComponent = textArea ? Input.TextArea : Input; + const { status } = Form.Item.useStatus(); + const { componentSize } = ConfigProvider.useConfig(); + const mySize = size || componentSize; + + // 删除 + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onRemove) { + onRemove(); + return; + } + + onChange?.({ + ...valueObj, + value: undefined, + showValue: undefined, + fromSelect: false, + activeTab: undefined, + expandedKeys: [], + checkedKeys: [], + }); + }; + + return ( + <> + {(isSelect || !canInput) && !disabled ? ( +
+ {valueObj?.showValue ? ( +
+ + {valueObj.showValue} + + +
+ ) : ( +
{placeholder}
+ )} +
+ ) : ( + + onChange?.({ + ...valueObj, + value: e.target.value, + showValue: e.target.value, + fromSelect: false, + }) + } + /> + )} + + ); +} + +export default ParameterInput; + +// 必填校验 +export const requiredValidator = (rule: RuleObject, value: any) => { + const trueValue = typeof value === 'object' ? value?.value : value; + if (!trueValue) { + return Promise.reject(rule.message || '必填项'); + } + return Promise.resolve(); +}; diff --git a/react-ui/src/components/ParameterSelect/config.tsx b/react-ui/src/components/ParameterSelect/config.tsx new file mode 100644 index 00000000..71b1ff6b --- /dev/null +++ b/react-ui/src/components/ParameterSelect/config.tsx @@ -0,0 +1,128 @@ +import { DatasetData, ModelData } from '@/pages/Dataset/config'; +import { ServiceData } from '@/pages/ModelDeployment/types'; +import { getDatasetList, getModelList } from '@/services/dataset/index.js'; +import { getServiceListReq } from '@/services/modelDeployment'; +import type { JCCResourceImage, JCCResourceStandard, JCCResourceType } from '@/state/jcdResource'; +import { filterResourceStandard, resourceFieldNames } from '@/state/systemResource'; +import { type SelectProps } from 'antd'; + +export type SelectPropsConfig = { + getOptions?: () => Promise; // 获取下拉数据 + fieldNames?: SelectProps['fieldNames']; // 下拉数据字段 + optionFilterProp?: SelectProps['optionFilterProp']; // 过滤字段名 + filterOption?: SelectProps['filterOption']; // 过滤函数 + isObjectValue: boolean; // value 是对象 + getValue?: (value: any) => string | number; // 对象类型时,获取其值 + getLabel?: (value: any) => string; // 对象类型时,获取其 label +}; + +export const ParameterSelectTypeList = [ + 'dataset', + 'model', + 'service', + 'resource', + 'remote-image', + 'remote-resource-type', + 'remote-resource', +] as const; + +export type ParameterSelectDataType = (typeof ParameterSelectTypeList)[number]; + +export const paramSelectConfig: Record = { + dataset: { + getOptions: async () => { + const res = await getDatasetList({ + page: 0, + size: 1000, + is_public: false, + }); + return res?.data?.content ?? []; + }, + optionFilterProp: 'label', + getValue: (value: DatasetData) => { + return value.id; + }, + getLabel: (value: DatasetData) => { + return value.name; + }, + isObjectValue: true, + }, + model: { + getOptions: async () => { + const res = await getModelList({ + page: 0, + size: 1000, + is_public: false, + }); + return res?.data?.content ?? []; + }, + optionFilterProp: 'label', + getValue: (value: ModelData) => { + return value.id; + }, + getLabel: (value: ModelData) => { + return value.name; + }, + isObjectValue: true, + }, + service: { + getOptions: async () => { + const res = await getServiceListReq({ + page: 0, + size: 1000, + }); + return res?.data?.content ?? []; + }, + optionFilterProp: 'label', + getValue: (value: ServiceData) => { + return value.id; + }, + getLabel: (value: ServiceData) => { + return value.service_name; + }, + isObjectValue: true, + }, + resource: { + fieldNames: resourceFieldNames, + filterOption: filterResourceStandard as SelectProps['filterOption'], + isObjectValue: false, + }, + 'remote-resource-type': { + optionFilterProp: 'label', + isObjectValue: false, + getValue: (value: JCCResourceType) => { + return value.value; + }, + getLabel: (value: JCCResourceType) => { + return value.label; + }, + }, + 'remote-image': { + optionFilterProp: 'label', + getValue: (value: JCCResourceImage) => { + return value.imageID; + }, + getLabel: (value: JCCResourceImage) => { + return value.name; + }, + isObjectValue: true, + }, + 'remote-resource': { + optionFilterProp: 'label', + getValue: (value: JCCResourceStandard) => { + return value.id; + }, + getLabel: (value: JCCResourceStandard) => { + const cpu = value.baseResourceSpecs.find((v) => v.type === 'CPU'); + const ram = value.baseResourceSpecs.find((v) => v.type === 'MEMORY' && v.name === 'RAM'); + const vram = value.baseResourceSpecs.find((v) => v.type === 'MEMORY' && v.name === 'VRAM'); + const cpuText = cpu ? `CPU:${cpu.availableValue}, ` : ''; + const ramText = ram ? `内存: ${ram.availableValue}${ram.availableUnit?.toUpperCase()}` : ''; + const vramText = vram + ? `(显存${vram.availableValue}${vram.availableUnit?.toUpperCase()})` + : ''; + return `${value.type}: ${value.availableCount}*${value.name}${vramText}, ${cpuText}${ramText}`; + }, + isObjectValue: true, + }, +}; diff --git a/react-ui/src/components/ParameterSelect/index.tsx b/react-ui/src/components/ParameterSelect/index.tsx new file mode 100644 index 00000000..bb23bc06 --- /dev/null +++ b/react-ui/src/components/ParameterSelect/index.tsx @@ -0,0 +1,172 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 08:42:57 + * @Description: 参数下拉选择组件,支持资源规格、数据集、模型、服务 + */ + +import jccResourceState, { getResourceTypes } from '@/state/jcdResource'; +import systemResourceState, { getSystemResources } from '@/state/systemResource'; +import { to } from '@/utils/promise'; +import { useSnapshot } from '@umijs/max'; +import { Select, type SelectProps } from 'antd'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import FormInfo from '../FormInfo'; +import { paramSelectConfig, type ParameterSelectDataType } from './config'; + +export { ParameterSelectTypeList, type ParameterSelectDataType } from './config'; + +export type ParameterSelectObject = { + value: any; + [key: string]: any; +}; + +type SelectOptions = SelectProps['options']; + +const identityFunc = (value: any) => value; + +export interface ParameterSelectProps extends SelectProps { + /** 类型 */ + dataType: ParameterSelectDataType; + /** 是否只是展示信息 */ + display?: boolean; + /** 值,支持对象,对象必须包含 value */ + value?: string | ParameterSelectObject; + /** 修改后回调 */ + onChange?: (value: string | ParameterSelectObject) => void; +} + +/** 参数选择器,支持资源规格、数据集、模型、服务 */ +function ParameterSelect({ + dataType, + display = false, + value, + onChange, + ...rest +}: ParameterSelectProps) { + const [options, setOptions] = useState([]); + const propsConfig = paramSelectConfig[dataType]; + const { + getLabel = identityFunc, + getValue = identityFunc, + getOptions, + filterOption, + fieldNames, + optionFilterProp, + isObjectValue, + } = propsConfig; + const selectValue = typeof value === 'object' && value !== null ? value.value : value; + // 数据集、模型、服务,对象转换成 json 字符串 + const valueText = + typeof selectValue === 'object' && selectValue !== null ? getValue(selectValue) : selectValue; + const jccResourceSnap = useSnapshot(jccResourceState); + const systemResourceSnap = useSnapshot(systemResourceState); + + const objectOptions = useMemo(() => { + return dataType === 'remote-resource-type' + ? jccResourceSnap.types + : dataType === 'remote-image' + ? jccResourceSnap.images + : dataType === 'remote-resource' + ? jccResourceSnap.resources + : options; + }, [dataType, options, jccResourceSnap.types, jccResourceSnap.images, jccResourceSnap.resources]); + + // 将对象类型转换为 Select Options + const converObjectToOptions = useCallback( + (v: any) => { + return { + label: getLabel(v), + value: getValue(v), + }; + }, + [getLabel, getValue], + ); + + // 数据集、模型、服务获取数据后,进行转换 + const objectSelectOptions = useMemo(() => { + return objectOptions?.map(converObjectToOptions); + }, [converObjectToOptions, objectOptions]); + + // 快速得到选中的对象 + const valueMap = useMemo(() => { + const map = new Map(); + objectOptions?.forEach((v) => { + map.set(getValue(v), v); + }); + + return map; + }, [objectOptions, getValue]); + + useEffect(() => { + // 获取下拉数据 + const getSelectOptions = async () => { + if (getOptions) { + const [res] = await to(getOptions()); + if (res) { + setOptions(res); + } + } else if (dataType === 'remote-resource-type') { + if (jccResourceSnap.types.length === 0) { + getResourceTypes(); + } + } else if (dataType === 'resource') { + getSystemResources(); + } + }; + getSelectOptions(); + }, [getOptions, dataType, getResourceTypes, jccResourceSnap.types]); + + const selectOptions = ( + dataType === 'resource' ? systemResourceSnap.resources : objectSelectOptions + ) as SelectOptions; + + const handleChange = (text: string) => { + // 数据集、模型、服务,转换成对象 + if (isObjectValue) { + // 设置为 null 是因为 ant g6 bug + // 如果值为 undefined 时, graph.changeData(data) 会保留前面的值 + const selectValue = text ? valueMap.get(text) : null; + if (typeof value === 'object' && value !== null) { + onChange?.({ + ...value, + value: selectValue, + }); + } else { + onChange?.(selectValue); + } + } else { + const selectValue = text ? text : ''; + if (typeof value === 'object' && value !== null) { + onChange?.({ + ...value, + value: selectValue, + }); + } else { + onChange?.(selectValue); + } + } + }; + + // 只用于展示,FormInfo 组件带有 Tooltip + if (display) { + return ( + + ); + } + + return ( + setSearchText(e.target.value)} + prefix={ + + } + // prefix={} + /> + { + return ( + + {nodeData.title as string} + + ); + }} + /> + +
+
+
{fileTitle}
+
+ {files.map((v) => ( +
+ {v.file_name} +
+ ))} +
+
+ {versionDesc && ( +
+
{`${config.name}版本描述`}
+
{versionDesc}
+
+ )} +
+ + + + ); +} + +export default ResourceSelectorModal; diff --git a/react-ui/src/components/RightContent/AvatarDropdown.tsx b/react-ui/src/components/RightContent/AvatarDropdown.tsx new file mode 100644 index 00000000..43cc54bc --- /dev/null +++ b/react-ui/src/components/RightContent/AvatarDropdown.tsx @@ -0,0 +1,197 @@ +import { clearSessionToken } from '@/access'; +import DefaultAvatar from '@/assets/img/avatar-default.png'; +import { getLabelStudioUrl } from '@/services/developmentEnvironment'; +import { setRemoteMenu } from '@/services/session'; +import { logout } from '@/services/system/auth'; +import { ClientInfo } from '@/types'; +import { sleep, to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import { gotoLoginPage, oauthLogout } from '@/utils/ui'; +import { LogoutOutlined, UserOutlined } from '@ant-design/icons'; +import { setAlpha } from '@ant-design/pro-components'; +import { useEmotionCss } from '@ant-design/use-emotion-css'; +import { useModel, useNavigate } from '@umijs/max'; +import { Avatar, Spin } from 'antd'; +import type { MenuInfo } from 'rc-menu/lib/interface'; +import React from 'react'; +import { flushSync } from 'react-dom'; +import HeaderDropdown from '../HeaderDropdown'; + +export type GlobalHeaderRightProps = { + menu?: boolean; + isHome?: boolean; +}; + +const Name = () => { + const { initialState } = useModel('@@initialState'); + const { currentUser } = initialState || {}; + + const nameClassName = useEmotionCss(({ token }) => { + return { + // width: '70px', + height: '48px', + overflow: 'hidden', + lineHeight: '48px', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + color: 'white', + [`@media only screen and (max-width: ${token.screenMD}px)`]: { + display: 'none', + }, + }; + }); + + return {currentUser?.nickName}; +}; + +const AvatarLogo = () => { + const { initialState } = useModel('@@initialState'); + const { currentUser } = initialState || {}; + + const avatarClassName = useEmotionCss(({ token }) => { + return { + marginRight: '8px', + color: token.colorPrimary, + verticalAlign: 'top', + background: setAlpha(token.colorBgContainer, 0.85), + [`@media only screen and (max-width: ${token.screenMD}px)`]: { + margin: 0, + }, + }; + }); + return ( + + ); +}; + +const AvatarDropdown: React.FC = ({ menu, isHome = false }) => { + const navigate = useNavigate(); + /** + * 退出登录,并且将当前的 url 保存 + */ + const loginOut = async () => { + // const { origin } = location; + const [res] = await to(getLabelStudioUrl()); + if (res && res.data) { + oauthLogout(`${res.data}/oauth/logout`); + } + // 至少 1 秒后跳转,希望子系统能完成注销 + await Promise.all([logout(), sleep(1000)]); + clearSessionToken(); + setRemoteMenu(null); + // 退出 oauth2 + const clientInfo: ClientInfo = SessionStorage.getItem(SessionStorage.clientInfoKey, true); + if (clientInfo) { + const { logoutUri } = clientInfo; + location.replace(logoutUri); + // if (isHome) { + // setTimeout(() => { + // location.replace(origin); + // }, 1); + // } + } else { + if (isHome) { + location.reload(); + } else { + gotoLoginPage(true); + } + } + }; + const actionClassName = useEmotionCss(({ token }) => { + return { + display: 'flex', + height: '48px', + marginLeft: 'auto', + overflow: 'hidden', + alignItems: 'center', + padding: '0 8px', + cursor: 'pointer', + borderRadius: token.borderRadius, + '&:hover': { + backgroundColor: token.colorBgTextHover, + }, + }; + }); + const { initialState, setInitialState } = useModel('@@initialState'); + + const onMenuClick = (event: MenuInfo) => { + const { key } = event; + if (key === 'logout') { + flushSync(() => { + setInitialState((s) => ({ ...s, currentUser: undefined })); + }); + loginOut(); + return; + } + navigate(`/account/${key}`); + }; + + const loading = ( + + + + ); + + if (!initialState) { + return loading; + } + + const { currentUser } = initialState; + + if (!currentUser || !currentUser.nickName) { + return loading; + } + + const menuItems = [ + ...(menu + ? [ + { + key: 'center', + icon: , + label: '个人中心', + }, + // { + // key: 'settings', + // icon: , + // label: '个人设置', + // }, + { + type: 'divider' as const, + }, + ] + : []), + { + key: 'logout', + icon: , + label: '退出登录', + }, + ]; + + return ( + + + + + + + ); +}; + +export default AvatarDropdown; diff --git a/react-ui/src/components/RightContent/index.less b/react-ui/src/components/RightContent/index.less new file mode 100644 index 00000000..096e4da4 --- /dev/null +++ b/react-ui/src/components/RightContent/index.less @@ -0,0 +1,9 @@ +.right-content { + display: flex; + gap: 10px; + align-items: center; + height: 60px; + padding: 0 14px; + // background-color: white; + // border-bottom: 1px solid #e9edf0; +} diff --git a/react-ui/src/components/RightContent/index.tsx b/react-ui/src/components/RightContent/index.tsx new file mode 100644 index 00000000..ede172a5 --- /dev/null +++ b/react-ui/src/components/RightContent/index.tsx @@ -0,0 +1,54 @@ +// import KFIcon from '@/components/KFIcon'; +// import { ProBreadcrumb } from '@ant-design/pro-components'; +import { useModel } from '@umijs/max'; +import React from 'react'; +import MessageBroadcast from '../MessageBroadcast'; +import Avatar from './AvatarDropdown'; +import styles from './index.less'; +// import { SelectLang } from '@umijs/max'; + +export type SiderTheme = 'light' | 'dark'; + +const GlobalHeaderRight: React.FC = () => { + const { initialState, setInitialState } = useModel('@@initialState'); + + if (!initialState || !initialState.settings) { + return null; + } + + // const handleMenuCollapse = () => { + // setInitialState((preInitialState) => ({ + // ...preInitialState, + // collapsed: !preInitialState?.collapsed, + // })); + // }; + + return ( +
+ {/* { + window.open('https://pro.ant.design/docs/getting-started'); + }} + > + + */} + + {/* */} + + {/* */} + + + + + + {/* */} +
+ ); +}; +export default GlobalHeaderRight; diff --git a/react-ui/src/components/RunDuration/index.tsx b/react-ui/src/components/RunDuration/index.tsx new file mode 100644 index 00000000..4a9a26cc --- /dev/null +++ b/react-ui/src/components/RunDuration/index.tsx @@ -0,0 +1,44 @@ +import { useServerTime } from '@/hooks/useServerTime'; +import { elapsedTime } from '@/utils/date'; +import React, { useEffect, useState } from 'react'; + +type RunDurationProps = { + createTime?: string; + finishTime?: string; + className?: string; + style?: React.CSSProperties; +}; +function RunDuration({ createTime, finishTime, className, style }: RunDurationProps) { + const [now] = useServerTime(); + const [currentTime, setCurrentTime] = useState(finishTime ? new Date(finishTime) : now()); + + // console.log( + // 'currentTime', + // new Date(createTime ?? 0), + // currentTime, + // (currentTime.getTime() - new Date(createTime ?? 0).getTime()) / 1000, + // ); + + // 定时刷新耗时 + useEffect(() => { + if (finishTime) { + setCurrentTime(new Date(finishTime)); + } else { + setCurrentTime(now()); + const timer = setInterval(() => { + setCurrentTime(now()); + }, 1000); + return () => { + clearInterval(timer); + }; + } + }, [finishTime, now]); + + return ( + + {elapsedTime(createTime, currentTime)} + + ); +} + +export default RunDuration; diff --git a/react-ui/src/components/SubAreaTitle/index.less b/react-ui/src/components/SubAreaTitle/index.less new file mode 100644 index 00000000..5e3b7452 --- /dev/null +++ b/react-ui/src/components/SubAreaTitle/index.less @@ -0,0 +1,6 @@ +.kf-subarea-title { + display: flex; + align-items: center; + color: @text-color; + font-size: 16px; +} diff --git a/react-ui/src/components/SubAreaTitle/index.tsx b/react-ui/src/components/SubAreaTitle/index.tsx new file mode 100644 index 00000000..4c94deee --- /dev/null +++ b/react-ui/src/components/SubAreaTitle/index.tsx @@ -0,0 +1,35 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-17 15:25:04 + * @Description: 分区标题 + */ + +import classNames from 'classnames'; +import './index.less'; + +type SubAreaTitleProps = { + /** 标题 */ + title: string; + /** 图片 */ + image?: string; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +}; + +/** + * 表单或者详情页的分区标题 + */ +function SubAreaTitle({ title, image, className, style }: SubAreaTitleProps) { + return ( +
+ {image && ( + + )} + {title} +
+ ); +} + +export default SubAreaTitle; diff --git a/react-ui/src/components/TableColTitle/index.less b/react-ui/src/components/TableColTitle/index.less new file mode 100644 index 00000000..51207953 --- /dev/null +++ b/react-ui/src/components/TableColTitle/index.less @@ -0,0 +1,3 @@ +.ant-table .ant-table-cell .kf-table-col-title { + margin-bottom: 0; +} diff --git a/react-ui/src/components/TableColTitle/index.tsx b/react-ui/src/components/TableColTitle/index.tsx new file mode 100644 index 00000000..0583f3ed --- /dev/null +++ b/react-ui/src/components/TableColTitle/index.tsx @@ -0,0 +1,32 @@ +/* + * @Author: 赵伟 + * @Date: 2025-03-11 10:52:23 + * @Description: 用于内容可变的表格类标题 + */ + +import { Typography } from 'antd'; +import classNames from 'classnames'; +import './index.less'; + +type TableColTitleProps = { + /** 标题 */ + title: string; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +}; + +function TableColTitle({ title, className, style }: TableColTitleProps) { + return ( + + {title} + + ); +} + +export default TableColTitle; diff --git a/react-ui/src/dayjsConfig.ts b/react-ui/src/dayjsConfig.ts new file mode 100644 index 00000000..199db3db --- /dev/null +++ b/react-ui/src/dayjsConfig.ts @@ -0,0 +1,3 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +dayjs.extend(duration); diff --git a/react-ui/src/enums/httpEnum.ts b/react-ui/src/enums/httpEnum.ts new file mode 100644 index 00000000..8b7ff1f2 --- /dev/null +++ b/react-ui/src/enums/httpEnum.ts @@ -0,0 +1,31 @@ +/** + * @description: Request result set + */ +export enum HttpResult { + SUCCESS = 200, + ERROR = -1, + TIMEOUT = 401, + TYPE = 'success', +} + +/** + * @description: request method + */ +export enum RequestMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', +} + +/** + * @description: contentType + */ +export enum ContentType { + // json + JSON = 'application/json;charset=UTF-8', + // form-data qs + FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8', + // form-data upload + FORM_DATA = 'multipart/form-data;charset=UTF-8', +} diff --git a/react-ui/src/enums/index.ts b/react-ui/src/enums/index.ts new file mode 100644 index 00000000..1b077b81 --- /dev/null +++ b/react-ui/src/enums/index.ts @@ -0,0 +1,199 @@ +/* + * @Author: 赵伟 + * @Date: 2024-06-07 11:22:28 + * @Description: 接口返回的枚举值和共用的枚举值定义在这里 + */ + +// 公开还是私有 TabKey +export enum CommonTabKeys { + Private = 'Private', // 私有 + Public = 'Public', // 公开 +} + +// 公开还是私有 +export enum AvailableRange { + Public = 1, // 公开 + Private = 0, // 私有 +} + +// 实验状态 +export enum ExperimentStatus { + Pending = 'Pending', // 启动中 + Running = 'Running', // 运行中 + Succeeded = 'Succeeded', // 成功 + Failed = 'Failed', // 失败 + Error = 'Error', // 错误 + Terminated = 'Terminated', // 终止 + Skipped = 'Skipped', // 跳过 + Omitted = 'Omitted', // 忽略 +} + +// TensorBoard 状态 +export enum TensorBoardStatus { + Unknown = 'Unknown', // 未知 + Pending = 'Pending', // 启动中 + Running = 'Running', // 运行中 + Terminated = 'Terminated', // 未启动 + Failed = 'Failed', // 失败 +} + +// 镜像版本状态 +export enum MirrorVersionStatus { + Available = 'Available', // 可用 + Building = 'Building', // 构建中 + Failed = 'Failed', // 失败 +} + +// 服务运行状态 +export enum ServiceRunStatus { + Init = 'Init', // 启动中 + Running = 'Running', // 运行中 + Stopped = 'Stopped', // 已停止 + Failed = 'Failed', // 失败 + Pending = 'Pending', // 挂起中 +} + +// 服务运行状态选项列表 +export const serviceStatusOptions = [ + { label: '启动中', value: ServiceRunStatus.Init }, + { label: '运行中', value: ServiceRunStatus.Running }, + { label: '已停止', value: ServiceRunStatus.Stopped }, + { label: '失败', value: ServiceRunStatus.Failed }, + { label: '挂起中', value: ServiceRunStatus.Pending }, +]; + +// 开发环境编辑器状态 +export enum DevEditorStatus { + Pending = 'Pending', // 启动中 + Running = 'Running', // 运行中 + Terminated = 'Terminated', // 已终止 + Failed = 'Failed', // 失败 + Unknown = 'Unknown', // 未启动 +} + +// 服务类型 +export enum ServiceType { + Video = 'video', + Image = 'image', + Audio = 'audio', + Text = 'text', +} + +export const serviceTypeOptions = [ + { label: '视频', value: ServiceType.Video }, + { label: '图片', value: ServiceType.Image }, + { label: '音频', value: ServiceType.Audio }, + { label: '文本', value: ServiceType.Text }, +]; + +// 自动化类型 +export enum AutoMLType { + Table = 'auto_ml', + Text = 'text_classification', + Video = 'video_classification', +} + +export const autoMLTypeOptions = [ + { label: '表格', value: AutoMLType.Table }, + { label: '文本', value: AutoMLType.Text }, + { label: '视频', value: AutoMLType.Video }, +]; + +// 自动化任务类型 +export enum AutoMLTaskType { + Classification = 'classification', + Regression = 'regression', +} + +export const autoMLTaskTypeOptions = [ + { label: '分类', value: AutoMLTaskType.Classification }, + { label: '回归', value: AutoMLTaskType.Regression }, +]; + +// 自动化任务集成策略 +export enum AutoMLEnsembleClass { + Default = 'default', + SingleBest = 'SingleBest', +} + +export const autoMLEnsembleClassOptions = [ + { label: '集成模型', value: AutoMLEnsembleClass.Default }, + { label: '单一最佳模型', value: AutoMLEnsembleClass.SingleBest }, +]; + +// 自动化任务重采样策略 +export enum AutoMLResamplingStrategy { + Holdout = 'holdout', + CrossValid = 'crossValid', +} + +export const autoMLResamplingStrategyOptions = [ + { label: 'holdout', value: AutoMLResamplingStrategy.Holdout }, + { label: 'crossValid', value: AutoMLResamplingStrategy.CrossValid }, +]; + +// 超参数自动寻优优化方向 +export enum hyperParameterOptimizedMode { + Min = 'min', + Max = 'max', +} + +export const hyperParameterOptimizedModeOptions = [ + { label: '越大越好', value: hyperParameterOptimizedMode.Max }, + { label: '越小越好', value: hyperParameterOptimizedMode.Min }, +]; + +// 超参数 Trail 运行状态 +export enum HyperParameterTrailStatus { + PENDING = 'PENDING', // 挂起 + RUNNING = 'RUNNING', // 运行中 + TERMINATED = 'TERMINATED', // 成功 + ERROR = 'ERROR', // 错误 + PAUSED = 'PAUSED', // 暂停 + RESTORING = 'RESTORING', // 恢复中 +} + +// 自动 Trail 运行状态 +export enum AutoMLTrailStatus { + TIMEOUT = 'TIMEOUT', // 超时 + SUCCESS = 'SUCCESS', // 成功 + FAILURE = 'FAILURE', // 失败 + CRASHED = 'CRASHED', // 崩溃 + STOP = 'STOP', // 停止 + CANCELLED = 'CANCELLED', // 取消 + MEMOUT = 'MEMOUT', // 内存溢出 +} + +// 流水线组件类型 +export enum ComponentType { + Ref = 'ref', + Select = 'select', + Map = 'map', + Str = 'str', +} + +// 消息类型 +export enum MessageType { + System = 1, + Mine = 2, +} + +// 消息状态 +export enum MessageStatus { + All = -1, + UnRead = 1, + Readed = 2, +} + +// 审核状态 +export enum ApprovalStatus { + Pending = 0, + Agree = 1, + Reject = 2, +} + +export const approvalStatusOptions = [ + { label: '待审核', value: ApprovalStatus.Pending }, + { label: '通过', value: ApprovalStatus.Agree }, + { label: '拒绝', value: ApprovalStatus.Reject }, +]; diff --git a/react-ui/src/enums/pagesEnums.ts b/react-ui/src/enums/pagesEnums.ts new file mode 100644 index 00000000..c8073a8c --- /dev/null +++ b/react-ui/src/enums/pagesEnums.ts @@ -0,0 +1,6 @@ +export enum PageEnum { + Root = '/', + LOGIN = '/user/login', + Authorize = '/authorize', + Home = '/home', +} diff --git a/react-ui/src/global.less b/react-ui/src/global.less new file mode 100644 index 00000000..c7b3c868 --- /dev/null +++ b/react-ui/src/global.less @@ -0,0 +1,159 @@ +html, +body, +#root { + min-width: 1440px; + height: 100%; + margin: 0; + padding: 0; + overflow-y: visible; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji'; +} + +.colorWeak { + filter: invert(80%); +} + +.ant-layout { + min-height: 100vh; +} +.ant-pro-sider.ant-layout-sider.ant-pro-sider-fixed { + left: unset; +} +canvas { + display: block; +} + +body { + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.ant-pro-layout .ant-pro-layout-content { + padding: 0 10px 10px; + background-color: transparent; +} +.ant-pro-layout .ant-pro-layout-bg-list { + background: @background-color; +} + +.ant-pro-base-menu-inline-item-title .ant-pro-base-menu-inline-item-text { + font-size: 16px; +} + +.ant-pro-layout .ant-pro-sider-menu { + padding-top: 15px; +} +.ant-pro-global-header-logo-mix { + padding-left: 12px; +} +.ant-pro-layout .ant-pro-sider .ant-layout-sider-children { + border-right: unset; +} +.ant-pro-base-menu-inline { + border-radius: 0px 20px 20px 0px; +} +.ant-drawer .ant-drawer-body { + padding: 0; +} +.ant-drawer .ant-drawer-body .ant-row { + padding: 0 24px; +} +.ant-drawer .ant-drawer-body .ant-form-item { + margin-bottom: 20px; +} +.ant-menu .ant-menu-submenu-title .anticon { + font-size: 16px; +} +.ant-table-wrapper .ant-table-pagination.ant-pagination { + margin: 0; + padding: 20px 16px; + background-color: #fff; +} +.ant-pro-global-header-logo img { + height: 21px; +} +.ant-pro-layout .ant-layout-sider.ant-pro-sider { + height: 100vh; + padding-top: 60px; +} +.ant-pro-layout .ant-pro-layout-container { + height: 100vh; + overflow-y: hidden; +} +.ant-pagination .ant-pagination-item.ant-pagination-item-active { + background: @primary-color; + border-width: 0; + + a { + color: #fff; + } +} +.ant-pagination .ant-pagination-item-active:hover { + color: #fff; + background: rgba(22, 100, 255, 0.8); + border-color: rgba(22, 100, 255, 0.8); +} +.ant-pagination .ant-pagination-item { + border: 1px solid #e6e6e6; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; + background: transparent; +} +::-webkit-scrollbar-thumb { + width: 8px; + height: 8px; + background: rgba(0, 0, 0, 0.5); + border-radius: 99px; +} +::-webkit-scrollbar-track { + width: 8px; + height: 8px; + background: transparent; +} +ul, +ol { + list-style: none; +} + +@media (max-width: 768px) { + .ant-table { + width: 100%; + overflow-x: auto; + &-thead > tr, + &-tbody > tr { + > th, + > td { + white-space: pre; + > span { + display: block; + } + } + } + } +} + +.kf-menu-collapsed { + position: fixed; + top: 0; + left: 0; + z-index: 999; +} + +.kf-table-row-link:hover { + text-decoration: underline @underline-color; + text-underline-offset: 3px; +} + +input:-webkit-autofill { + transition: background-color 5000s ease-in-out 0s; +} + +.ant-typography { + color: inherit; + font-size: inherit; +} diff --git a/react-ui/src/global.tsx b/react-ui/src/global.tsx new file mode 100644 index 00000000..8213cbf5 --- /dev/null +++ b/react-ui/src/global.tsx @@ -0,0 +1,117 @@ +import { useIntl } from '@umijs/max'; +import { Button, message, notification } from 'antd'; +import defaultSettings from '../config/defaultSettings'; + +const { pwa } = defaultSettings; +const isHttps = document.location.protocol === 'https:'; + +const clearCache = () => { + // remove all caches + if (window.caches) { + caches + .keys() + .then((keys) => { + keys.forEach((key) => { + caches.delete(key); + }); + }) + .catch((e) => console.error(e)); + } +}; + +const doubleText = () => { + if (process.env.NODE_ENV === 'development') { + document.body.addEventListener( + 'click', + (e) => { + const target = e.target; + if ( + // e.altKey && + e.ctrlKey && + target && + target.innerText && + target.nodeType === Node.ELEMENT_NODE + ) { + e.stopPropagation(); + e.preventDefault(); + const times = 2; + target.innerText = target.innerText.repeat(times); + } + }, + true, + ); + } +}; + +// if pwa is true +if (pwa) { + // Notify user if offline now + window.addEventListener('sw.offline', () => { + message.warning(useIntl().formatMessage({ id: 'app.pwa.offline' })); + }); + + // Pop up a prompt on the page asking the user if they want to use the latest version + window.addEventListener('sw.updated', (event: Event) => { + const e = event as CustomEvent; + const reloadSW = async () => { + // Check if there is sw whose state is waiting in ServiceWorkerRegistration + // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration + const worker = e.detail && e.detail.waiting; + if (!worker) { + return true; + } + // Send skip-waiting event to waiting SW with MessageChannel + await new Promise((resolve, reject) => { + const channel = new MessageChannel(); + channel.port1.onmessage = (msgEvent) => { + if (msgEvent.data.error) { + reject(msgEvent.data.error); + } else { + resolve(msgEvent.data); + } + }; + worker.postMessage({ type: 'skip-waiting' }, [channel.port2]); + }); + + clearCache(); + window.location.reload(); + return true; + }; + const key = `open${Date.now()}`; + const btn = ( + + ); + notification.open({ + message: useIntl().formatMessage({ id: 'app.pwa.serviceworker.updated' }), + description: useIntl().formatMessage({ id: 'app.pwa.serviceworker.updated.hint' }), + btn, + key, + onClose: async () => null, + }); + }); +} else if ('serviceWorker' in navigator && isHttps) { + // unregister service worker + const { serviceWorker } = navigator; + if (serviceWorker.getRegistrations) { + serviceWorker.getRegistrations().then((sws) => { + sws.forEach((sw) => { + sw.unregister(); + }); + }); + } + serviceWorker.getRegistration().then((sw) => { + if (sw) sw.unregister(); + }); + + clearCache(); +} + +doubleText(); diff --git a/react-ui/src/hooks/dict.ts b/react-ui/src/hooks/dict.ts new file mode 100644 index 00000000..e474b9f2 --- /dev/null +++ b/react-ui/src/hooks/dict.ts @@ -0,0 +1,6 @@ +import { getDictValueEnum } from '@/services/system/dict'; + +export function useDictEnum(name: string) { + const data = getDictValueEnum(name); + return data; +} diff --git a/react-ui/src/hooks/useCacheState.ts b/react-ui/src/hooks/useCacheState.ts new file mode 100644 index 00000000..78b16fa7 --- /dev/null +++ b/react-ui/src/hooks/useCacheState.ts @@ -0,0 +1,60 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-28 11:49:48 + * @Description: 页面状态缓存,pop 回到这个页面的时候,重新构建之前的状态 + */ + +import { parseJsonText } from '@/utils'; +import { useCallback, useState } from 'react'; + +const pageKeys: string[] = []; +// let stateCaches: Record = {}; + +// 获取页面缓存 +const getCacheState = (key: string) => { + const jsonStr = sessionStorage.getItem(key); + if (jsonStr) { + removeCacheState(key); + return parseJsonText(jsonStr); + } + return undefined; +}; + +// 移除页面 state 缓存 +const removeCacheState = (key: string) => { + sessionStorage.removeItem(key); + const index = pageKeys.indexOf(key); + if (index !== -1) { + pageKeys.splice(index, 1); + } +}; + +/** + * 移除所有页面 state 缓存 + */ +export const removeAllPageCacheState = () => { + pageKeys.forEach((key) => { + sessionStorage.removeItem(key); + }); +}; + +/** + * 缓存页面数据 + */ +export const useCacheState = () => { + const { pathname } = window.location; + const key = 'pagecache:' + pathname; + + const setCacheState = useCallback( + (state?: any) => { + if (state) { + pageKeys.push(key); + sessionStorage.setItem(key, JSON.stringify(state)); + } + }, + [key], + ); + + const [cacheState] = useState(() => getCacheState(key)); + return [cacheState, setCacheState] as const; +}; diff --git a/react-ui/src/hooks/useCallbackState.ts b/react-ui/src/hooks/useCallbackState.ts new file mode 100644 index 00000000..820e770b --- /dev/null +++ b/react-ui/src/hooks/useCallbackState.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef, useState } from 'react'; + +type Callback = (state: T) => void; + +/** + * 生成一个具有回调机制的可变状态值和更新它的函数。谨慎使用 + * + * @param initialValue - 初始状态值。 + * @return 一个元组,包含当前状态值和用于更新状态的函数。 + */ +export function useCallbackState(initialValue: T) { + const [state, _setState] = useState(initialValue); + const callbackQueue = useRef[]>([]); + useEffect(() => { + callbackQueue.current.forEach((cb) => cb(state)); + callbackQueue.current = []; + }, [state]); + const setState = (newValue: T | ((prevState: T) => T), callback?: Callback) => { + _setState(newValue); + if (callback && typeof callback === 'function') { + callbackQueue.current.push(callback); + } + }; + return [state, setState] as const; +} diff --git a/react-ui/src/hooks/useCheck.ts b/react-ui/src/hooks/useCheck.ts new file mode 100644 index 00000000..3c9c985b --- /dev/null +++ b/react-ui/src/hooks/useCheck.ts @@ -0,0 +1,47 @@ +import { useCallback, useMemo, useState } from 'react'; + +/** + * 选择、全选操作 + * @param list - 需要进行选择的列表 + * @return [选中的项, 设置选中的方法, 是否全选, 是否部分选中, 全选方法,是否单个选中,选中单个方法] + */ +export const useCheck = (list: T[]) => { + const [selected, setSelected] = useState([]); + + const checked = useMemo(() => { + return selected.length === list.length && selected.length > 0; + }, [selected, list]); + + const indeterminate = useMemo(() => { + return selected.length > 0 && selected.length < list.length; + }, [selected, list]); + + const checkAll = useCallback(() => { + setSelected(checked ? [] : list); + }, [list, checked]); + + const isSingleChecked = useCallback((item: T) => selected.includes(item), [selected]); + + const checkSingle = useCallback( + (item: T) => { + setSelected((prev) => { + if (isSingleChecked(item)) { + return prev.filter((i) => i !== item); + } else { + return [...prev, item]; + } + }); + }, + [isSingleChecked], + ); + + return [ + selected, + setSelected, + checked, + indeterminate, + checkAll, + isSingleChecked, + checkSingle, + ] as const; +}; diff --git a/react-ui/src/hooks/useComputingResource.ts b/react-ui/src/hooks/useComputingResource.ts new file mode 100644 index 00000000..9eb5aa62 --- /dev/null +++ b/react-ui/src/hooks/useComputingResource.ts @@ -0,0 +1,93 @@ +/* + * @Author: 赵伟 + * @Date: 2024-10-10 08:51:41 + * @Description: 资源规格 hook + */ + +// import { getComputingResourceReq } from '@/services/pipeline'; +// import { ComputingResource } from '@/types'; +// import { to } from '@/utils/promise'; +// import { type SelectProps } from 'antd'; +// import { useCallback, useEffect, useState } from 'react'; + +// const computingResource: ComputingResource[] = []; + +// /** 过滤资源规格 */ +// export const filterResourceStandard: SelectProps['filterOption'] = ( +// input: string, +// option?: ComputingResource, +// ) => { +// return ( +// option?.computing_resource?.toLocaleLowerCase()?.includes(input.toLocaleLowerCase()) ?? false +// ); +// }; + +// /** 资源规格字段 */ +// export const resourceFieldNames = { +// label: 'description', +// value: 'id', +// }; + +// /** 获取资源规格 */ +// export function useComputingResource() { +// const [resourceStandardList, setResourceStandardList] = useState([]); + +// useEffect(() => { +// // 获取资源规格列表数据 +// const getComputingResource = async () => { +// const params = { +// page: 0, +// size: 1000, +// resource_type: '', +// }; +// const [res] = await to(getComputingResourceReq(params)); +// if (res && res.data && Array.isArray(res.data.content)) { +// setResourceStandardList(res.data.content); +// computingResource.splice(0, computingResource.length, ...res.data.content); +// } +// }; + +// if (computingResource.length > 0) { +// setResourceStandardList(computingResource); +// } else { +// getComputingResource(); +// } +// }, []); + +// // 根据 standard 获取 description +// const getDescription = useCallback( +// (id?: string | number) => { +// if (!id) { +// return undefined; +// } +// return resourceStandardList.find((item) => Number(item.id) === Number(id))?.description; +// }, +// [resourceStandardList], +// ); + +// return [resourceStandardList, getDescription] as const; +// } + +import state, { getSystemResources } from '@/state/systemResource'; +import { useSnapshot } from '@umijs/max'; +import { useCallback, useEffect } from 'react'; + +export const useSystemResource = () => { + useEffect(() => { + getSystemResources(); + }, []); + + const snap = useSnapshot(state); + /* 根据 standard 获取 description */ + const getDescription = useCallback( + (id?: string | number) => { + if (!id) { + return undefined; + } + return snap.resources.find((item) => Number(item.id) === Number(id))?.description; + }, + [snap.resources], + ); + + return getDescription; +}; diff --git a/react-ui/src/hooks/useDomSize.ts b/react-ui/src/hooks/useDomSize.ts new file mode 100644 index 00000000..ba1992ea --- /dev/null +++ b/react-ui/src/hooks/useDomSize.ts @@ -0,0 +1,40 @@ +import { debounce } from 'lodash'; +import { useEffect, useRef, useState } from 'react'; + +/** + * 用于追踪 DOM 元素尺寸的 hook。 + * + * @param initialWidth - 初始宽度。 + * @param initialHeight - 初始高度。 + * @param deps - 依赖列表。 + * @return 一个元组,包含 DOM 元素的 ref、当前宽度和当前高度。 + */ +export function useDomSize( + initialWidth: number, + initialHeight: number, + deps: React.DependencyList = [], +) { + const domRef = useRef(null); + const [width, setWidth] = useState(initialWidth); + const [height, setHeight] = useState(initialHeight); + + useEffect(() => { + const setDomHeight = () => { + if (domRef.current) { + setHeight(domRef.current.offsetHeight); + setWidth(domRef.current.offsetWidth); + } + }; + const debounceFunc = debounce(setDomHeight, 100); + + setDomHeight(); + window.addEventListener('resize', debounceFunc); + + return () => { + window.removeEventListener('resize', debounceFunc); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return [domRef, { width, height }] as const; +} diff --git a/react-ui/src/hooks/useDraggable.ts b/react-ui/src/hooks/useDraggable.ts new file mode 100644 index 00000000..121aaafa --- /dev/null +++ b/react-ui/src/hooks/useDraggable.ts @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +/** + * 处理 react-draggable 组件拖动结束时,响应了点击事件的 + */ +export const useDraggable = (onClick: () => void) => { + const [isDragging, setIsDragging] = useState(false); + + const handleStart = () => { + setIsDragging(false); + }; + + const handleDrag = () => { + if (!isDragging) { + setIsDragging(true); + } + }; + + const handleStop = () => { + // 延迟设置 isDragging 为 false 是为了确保在点击事件触发之前它仍然为 true + setTimeout(() => setIsDragging(false), 0); + }; + + const handleClick = (e: React.MouseEvent) => { + if (isDragging) { + e.preventDefault(); + e.stopPropagation(); + } else { + onClick(); + } + }; + + return { + handleStart, + handleDrag, + handleStop, + handleClick, + }; +}; diff --git a/react-ui/src/hooks/useEffectWhen.ts b/react-ui/src/hooks/useEffectWhen.ts new file mode 100644 index 00000000..12f1aad0 --- /dev/null +++ b/react-ui/src/hooks/useEffectWhen.ts @@ -0,0 +1,24 @@ +import { useEffect, useRef } from 'react'; + +/** + * 当指定的条件为真时执行 Effect 函数。 + * + * @param effect - The effect function to execute. + * @param when - The condition to trigger the effect. + * @param deps - The dependencies for the effect. + */ +export const useEffectWhen = (effect: () => void, when: boolean, deps: React.DependencyList) => { + const requestFn = useRef<(() => void) | undefined>(effect); + useEffect(() => { + requestFn.current = effect; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, effect]); + + useEffect(() => { + if (when && requestFn.current) { + requestFn.current(); + requestFn.current = undefined; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, when]); +}; diff --git a/react-ui/src/hooks/useResetForm.ts b/react-ui/src/hooks/useResetForm.ts new file mode 100644 index 00000000..acefd84e --- /dev/null +++ b/react-ui/src/hooks/useResetForm.ts @@ -0,0 +1,24 @@ +import { FormInstance } from 'antd'; +import { useEffect, useRef } from 'react'; + +/** + * 用于在 modal 关闭时重置 Form 表单的 hook。 + * + * @param form - Ant Design Form 表单实例 + * @param open - modal 是否打开 + */ +export const useResetForm = (form: FormInstance, open: boolean) => { + const prevOpenRef = useRef(); + + useEffect(() => { + prevOpenRef.current = open; + }, [open]); + + const prevOpen = prevOpenRef.current; + + useEffect(() => { + if (!open && prevOpen) { + form.resetFields(); + } + }, [form, prevOpen, open]); +}; diff --git a/react-ui/src/hooks/useSSE.ts b/react-ui/src/hooks/useSSE.ts new file mode 100644 index 00000000..1d31fdd2 --- /dev/null +++ b/react-ui/src/hooks/useSSE.ts @@ -0,0 +1,52 @@ +import { ExperimentStatus } from '@/enums'; +import { NodeStatus } from '@/types'; +import { parseJsonText } from '@/utils'; +import { useEffect } from 'react'; + +export type MessageHandler = ( + experimentId: number, + experimentInsId: number, + status: string, + finishTime: string, + nodes: Record, +) => void; +export const useSSE = ( + experimentId: number, + experimentInsId: number, + status: ExperimentStatus, + name: string, + namespace: string, + onMessage: MessageHandler, +) => { + const isRunning = status === ExperimentStatus.Pending || status === ExperimentStatus.Running; + useEffect(() => { + if (isRunning) { + const { origin } = location; + const params = encodeURIComponent(`metadata.namespace=${namespace},metadata.name=${name}`); + const evtSource = new EventSource( + `${origin}/api/v1/realtimeStatus?listOptions.fieldSelector=${params}`, + { withCredentials: false }, + ); + evtSource.onmessage = (event) => { + const data = event?.data; + if (!data) { + return; + } + const dataJson = parseJsonText(data); + const statusData = dataJson?.result?.object?.status; + if (statusData) { + const { finishedAt, phase, nodes } = statusData; + onMessage(experimentId, experimentInsId, phase, finishedAt, nodes); + } + }; + + evtSource.onerror = (error) => { + console.error('SSE error: ', error); + }; + + return () => { + evtSource.close(); + }; + } + }, [experimentId, experimentInsId, isRunning, name, namespace, onMessage]); +}; diff --git a/react-ui/src/hooks/useServerTime.ts b/react-ui/src/hooks/useServerTime.ts new file mode 100644 index 00000000..fcf8469d --- /dev/null +++ b/react-ui/src/hooks/useServerTime.ts @@ -0,0 +1,55 @@ +/* + * @Author: 赵伟 + * @Date: 2024-10-10 08:51:41 + * @Description: 服务器时间 hook + */ + +import { getSeverTimeReq } from '@/services/experiment'; +import { to } from '@/utils/promise'; +import { useCallback, useEffect, useState } from 'react'; + +let globalTimeOffset: number | undefined = undefined; + +// 获取服务器时间偏移 +export const globalGetSeverTime = async () => { + const requestStartTime = Date.now(); + const [res] = await to(getSeverTimeReq()); + const requestEndTime = Date.now(); + const requestDuration = (requestEndTime - requestStartTime) / 2; + if (res && res.data) { + const serverDate = new Date(res.data); + const timeOffset = serverDate.getTime() + requestDuration - requestEndTime; + globalTimeOffset = timeOffset; + return timeOffset; + } +}; + +// 服务器的当前时间 +export const serverNow = () => { + return new Date(Date.now() + (globalTimeOffset ?? 0)); +}; + +/** 获取服务器时间 */ +export function useServerTime() { + const [timeOffset, setTimeOffset] = useState(globalTimeOffset ?? 0); + + useEffect(() => { + const getSeverTime = async () => { + const [res] = await to(globalGetSeverTime()); + if (res) { + setTimeOffset(res); + } + }; + + // 获取服务器时间,防止第一次加载时,请求失败 + if (!globalTimeOffset) { + getSeverTime(); + } + }, []); + + const now = useCallback(() => { + return new Date(Date.now() + timeOffset); + }, [timeOffset]); + + return [now] as const; +} diff --git a/react-ui/src/hooks/useStateRef.ts b/react-ui/src/hooks/useStateRef.ts new file mode 100644 index 00000000..5cc260b3 --- /dev/null +++ b/react-ui/src/hooks/useStateRef.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * 生成具有初始值的状态引用 + * + * @param initialValue - 状态的初始值 + * @return 包含状态值、状态设置函数和可变引用对象的数组 + */ +export function useStateRef(initialValue: T) { + const [value, setValue] = useState(initialValue); + + const ref = useRef(value); + + useEffect(() => { + ref.current = value; + }, [value]); + + return [value, setValue, ref] as const; +} diff --git a/react-ui/src/hooks/useVisible.ts b/react-ui/src/hooks/useVisible.ts new file mode 100644 index 00000000..461fa3c8 --- /dev/null +++ b/react-ui/src/hooks/useVisible.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * 生成一个自定义钩子,用于管理模态框的可见性状态。 + * + * @param initialValue - 模态框的初始可见性状态。 + * @return 一个数组,包含 visible、打开函数、关闭函数和 visible ref。 + */ +export function useVisible(initialValue: boolean) { + const [visible, setVisible] = useState(initialValue); + const ref = useRef(initialValue); + + const open = useCallback(() => { + setVisible(true); + }, []); + + const close = useCallback(() => { + setVisible(false); + }, []); + + useEffect(() => { + ref.current = visible; + }, [visible]); + + return [visible, open, close, ref] as const; +} diff --git a/react-ui/src/iconfont/iconfont-menu.js b/react-ui/src/iconfont/iconfont-menu.js new file mode 100644 index 00000000..f527568a --- /dev/null +++ b/react-ui/src/iconfont/iconfont-menu.js @@ -0,0 +1 @@ +window._iconfont_svg_string_4511326='',(t=>{var a=(l=(l=document.getElementsByTagName("script"))[l.length-1]).getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var i,h,o,c,e,m=function(a,l){l.parentNode.insertBefore(a,l)};if(a&&!t.__iconfont__svg__cssinject__){t.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}i=function(){var a,l=document.createElement("div");l.innerHTML=t._iconfont_svg_string_4511326,(l=l.getElementsByTagName("svg")[0])&&(l.setAttribute("aria-hidden","true"),l.style.position="absolute",l.style.width=0,l.style.height=0,l.style.overflow="hidden",l=l,(a=document.body).firstChild?m(l,a.firstChild):a.appendChild(l))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(i,0):(h=function(){document.removeEventListener("DOMContentLoaded",h,!1),i()},document.addEventListener("DOMContentLoaded",h,!1)):document.attachEvent&&(o=i,c=t.document,e=!1,n(),c.onreadystatechange=function(){"complete"==c.readyState&&(c.onreadystatechange=null,p())})}function p(){e||(e=!0,o())}function n(){try{c.documentElement.doScroll("left")}catch(a){return void setTimeout(n,50)}p()}})(window); \ No newline at end of file diff --git a/react-ui/src/iconfont/iconfont-menu.json b/react-ui/src/iconfont/iconfont-menu.json new file mode 100644 index 00000000..cb25a583 --- /dev/null +++ b/react-ui/src/iconfont/iconfont-menu.json @@ -0,0 +1,177 @@ +{ + "id": "4511326", + "name": "复杂智能软件-导航", + "font_family": "iconfont", + "css_prefix_text": "icon-", + "description": "", + "glyphs": [ + { + "icon_id": "42495274", + "name": "知识图谱-active", + "font_class": "zhishitupu-icon-active", + "unicode": "e63e", + "unicode_decimal": 58942 + }, + { + "icon_id": "42495275", + "name": "知识图谱", + "font_class": "zhishitupu-icon", + "unicode": "e63f", + "unicode_decimal": 58943 + }, + { + "icon_id": "41643218", + "name": "模型开发", + "font_class": "model-icon", + "unicode": "e624", + "unicode_decimal": 58916 + }, + { + "icon_id": "41643132", + "name": "工作空间", + "font_class": "workspace-icon", + "unicode": "e628", + "unicode_decimal": 58920 + }, + { + "icon_id": "41643135", + "name": "系统管理", + "font_class": "system-icon", + "unicode": "e622", + "unicode_decimal": 58914 + }, + { + "icon_id": "41643131", + "name": "数据准备", + "font_class": "datasetPreparation-icon", + "unicode": "e625", + "unicode_decimal": 58917 + }, + { + "icon_id": "41643133", + "name": "开发环境", + "font_class": "developmentEnvironment-icon", + "unicode": "e626", + "unicode_decimal": 58918 + }, + { + "icon_id": "41642989", + "name": "使用手册", + "font_class": "manual-icon", + "unicode": "e623", + "unicode_decimal": 58915 + }, + { + "icon_id": "40233218", + "name": "操作手册-active", + "font_class": "manual-icon-active", + "unicode": "e62c", + "unicode_decimal": 58924 + }, + { + "icon_id": "40171713", + "name": "监控运维-active", + "font_class": "monitor-icon-active", + "unicode": "e627", + "unicode_decimal": 58919 + }, + { + "icon_id": "40171711", + "name": "监控运维", + "font_class": "monitor-icon", + "unicode": "e629", + "unicode_decimal": 58921 + }, + { + "icon_id": "40171710", + "name": "开发环境-active", + "font_class": "developmentEnvironment-icon-active", + "unicode": "e62a", + "unicode_decimal": 58922 + }, + { + "icon_id": "39969573", + "name": "流水线-active", + "font_class": "workflow-icon-active", + "unicode": "e61a", + "unicode_decimal": 58906 + }, + { + "icon_id": "39969569", + "name": "数据准备-active", + "font_class": "datasetPreparation-icon-active", + "unicode": "e61c", + "unicode_decimal": 58908 + }, + { + "icon_id": "39969570", + "name": "模型在线部署", + "font_class": "modelDseployment-icon", + "unicode": "e61e", + "unicode_decimal": 58910 + }, + { + "icon_id": "39969567", + "name": "流水线", + "font_class": "workflow-icon", + "unicode": "e61f", + "unicode_decimal": 58911 + }, + { + "icon_id": "39969566", + "name": "AI资产管理-active", + "font_class": "aiAsset-icon-active", + "unicode": "e620", + "unicode_decimal": 58912 + }, + { + "icon_id": "39969563", + "name": "AI资产管理", + "font_class": "aiAsset-icon", + "unicode": "e621", + "unicode_decimal": 58913 + }, + { + "icon_id": "39969572", + "name": "模型开发-active", + "font_class": "model-icon-active", + "unicode": "e610", + "unicode_decimal": 58896 + }, + { + "icon_id": "39969579", + "name": "系统管理-active", + "font_class": "system-icon-active", + "unicode": "e612", + "unicode_decimal": 58898 + }, + { + "icon_id": "39969578", + "name": "模型在线部署-active", + "font_class": "modelDseployment-icon-active", + "unicode": "e613", + "unicode_decimal": 58899 + }, + { + "icon_id": "39969577", + "name": "应用开发", + "font_class": "appsDeployment-icon", + "unicode": "e615", + "unicode_decimal": 58901 + }, + { + "icon_id": "39969576", + "name": "工作空间-active", + "font_class": "workspace-icon-active", + "unicode": "e616", + "unicode_decimal": 58902 + }, + { + "icon_id": "39969574", + "name": "应用开发-active", + "font_class": "appsDeployment-icon-active", + "unicode": "e617", + "unicode_decimal": 58903 + } + ] +} diff --git a/react-ui/src/iconfont/iconfont.js b/react-ui/src/iconfont/iconfont.js new file mode 100644 index 00000000..38c8ec18 --- /dev/null +++ b/react-ui/src/iconfont/iconfont.js @@ -0,0 +1 @@ +window._iconfont_svg_string_4511447='',(t=>{var a=(h=(h=document.getElementsByTagName("script"))[h.length-1]).getAttribute("data-injectcss"),h=h.getAttribute("data-disable-injectsvg");if(!h){var l,v,z,i,o,p=function(a,h){h.parentNode.insertBefore(a,h)};if(a&&!t.__iconfont__svg__cssinject__){t.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(a){console&&console.log(a)}}l=function(){var a,h=document.createElement("div");h.innerHTML=t._iconfont_svg_string_4511447,(h=h.getElementsByTagName("svg")[0])&&(h.setAttribute("aria-hidden","true"),h.style.position="absolute",h.style.width=0,h.style.height=0,h.style.overflow="hidden",h=h,(a=document.body).firstChild?p(h,a.firstChild):a.appendChild(h))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(l,0):(v=function(){document.removeEventListener("DOMContentLoaded",v,!1),l()},document.addEventListener("DOMContentLoaded",v,!1)):document.attachEvent&&(z=l,i=t.document,o=!1,d(),i.onreadystatechange=function(){"complete"==i.readyState&&(i.onreadystatechange=null,m())})}function m(){o||(o=!0,z())}function d(){try{i.documentElement.doScroll("left")}catch(a){return void setTimeout(d,50)}m()}})(window); \ No newline at end of file diff --git a/react-ui/src/icons/magnifying-glass.svg b/react-ui/src/icons/magnifying-glass.svg new file mode 100644 index 00000000..69bf383b --- /dev/null +++ b/react-ui/src/icons/magnifying-glass.svg @@ -0,0 +1,3 @@ + + + diff --git a/react-ui/src/locales/en-US.ts b/react-ui/src/locales/en-US.ts new file mode 100644 index 00000000..58407d29 --- /dev/null +++ b/react-ui/src/locales/en-US.ts @@ -0,0 +1,27 @@ +import app from './en-US/app'; +import component from './en-US/component'; +import globalHeader from './en-US/globalHeader'; +import menu from './en-US/menu'; +import pages from './en-US/pages'; +import pwa from './en-US/pwa'; +import settingDrawer from './en-US/settingDrawer'; +import settings from './en-US/settings'; + +export default { + 'navBar.lang': 'Languages', + 'layout.user.link.help': 'Help', + 'layout.user.link.privacy': 'Privacy', + 'layout.user.link.terms': 'Terms', + 'app.copyright.produced': 'Produced by Ant Financial Experience Department', + 'app.preview.down.block': 'Download this page to your local project', + 'app.welcome.link.fetch-blocks': 'Get all block', + 'app.welcome.link.block-list': 'Quickly build standard, pages based on `block` development', + ...app, + ...globalHeader, + ...menu, + ...settingDrawer, + ...settings, + ...pwa, + ...component, + ...pages, +}; diff --git a/react-ui/src/locales/en-US/app.ts b/react-ui/src/locales/en-US/app.ts new file mode 100644 index 00000000..6a196446 --- /dev/null +++ b/react-ui/src/locales/en-US/app.ts @@ -0,0 +1,26 @@ +export default { + 'app.docs.components.icon.search.placeholder': 'Search icons here, click icon to copy code', + 'app.docs.components.icon.outlined': 'Outlined', + 'app.docs.components.icon.filled': 'Filled', + 'app.docs.components.icon.two-tone': 'Two Tone', + 'app.docs.components.icon.category.direction': 'Directional Icons', + 'app.docs.components.icon.category.suggestion': 'Suggested Icons', + 'app.docs.components.icon.category.editor': 'Editor Icons', + 'app.docs.components.icon.category.data': 'Data Icons', + 'app.docs.components.icon.category.other': 'Application Icons', + 'app.docs.components.icon.category.logo': 'Brand and Logos', + 'app.docs.components.icon.pic-searcher.intro': + 'AI Search by image is online, you are welcome to use it! 🎉', + 'app.docs.components.icon.pic-searcher.title': 'Search by image', + 'app.docs.components.icon.pic-searcher.upload-text': + 'Click, drag, or paste file to this area to upload', + 'app.docs.components.icon.pic-searcher.upload-hint': + 'We will find the best matching icon based on the image provided', + 'app.docs.components.icon.pic-searcher.server-error': + 'Predict service is temporarily unavailable', + 'app.docs.components.icon.pic-searcher.matching': 'Matching...', + 'app.docs.components.icon.pic-searcher.modelloading': 'Model is loading...', + 'app.docs.components.icon.pic-searcher.result-tip': 'Matched the following icons for you:', + 'app.docs.components.icon.pic-searcher.th-icon': 'Icon', + 'app.docs.components.icon.pic-searcher.th-score': 'Probability', +}; diff --git a/react-ui/src/locales/en-US/component.ts b/react-ui/src/locales/en-US/component.ts new file mode 100644 index 00000000..3ba7eeda --- /dev/null +++ b/react-ui/src/locales/en-US/component.ts @@ -0,0 +1,5 @@ +export default { + 'component.tagSelect.expand': 'Expand', + 'component.tagSelect.collapse': 'Collapse', + 'component.tagSelect.all': 'All', +}; diff --git a/react-ui/src/locales/en-US/globalHeader.ts b/react-ui/src/locales/en-US/globalHeader.ts new file mode 100644 index 00000000..60b6d4ec --- /dev/null +++ b/react-ui/src/locales/en-US/globalHeader.ts @@ -0,0 +1,17 @@ +export default { + 'component.globalHeader.search': 'Search', + 'component.globalHeader.search.example1': 'Search example 1', + 'component.globalHeader.search.example2': 'Search example 2', + 'component.globalHeader.search.example3': 'Search example 3', + 'component.globalHeader.help': 'Help', + 'component.globalHeader.notification': 'Notification', + 'component.globalHeader.notification.empty': 'You have viewed all notifications.', + 'component.globalHeader.message': 'Message', + 'component.globalHeader.message.empty': 'You have viewed all messsages.', + 'component.globalHeader.event': 'Event', + 'component.globalHeader.event.empty': 'You have viewed all events.', + 'component.noticeIcon.clear': 'Clear', + 'component.noticeIcon.cleared': 'Cleared', + 'component.noticeIcon.empty': 'No notifications', + 'component.noticeIcon.view-more': 'View more', +}; diff --git a/react-ui/src/locales/en-US/menu.ts b/react-ui/src/locales/en-US/menu.ts new file mode 100644 index 00000000..eae3e532 --- /dev/null +++ b/react-ui/src/locales/en-US/menu.ts @@ -0,0 +1,52 @@ +export default { + 'menu.welcome': 'Welcome', + 'menu.more-blocks': 'More Blocks', + 'menu.home': 'Home', + 'menu.admin': 'Admin', + 'menu.admin.sub-page': 'Sub-Page', + 'menu.login': 'Login', + 'menu.register': 'Register', + 'menu.register-result': 'Register Result', + 'menu.dashboard': 'Dashboard', + 'menu.dashboard.analysis': 'Analysis', + 'menu.dashboard.monitor': 'Monitor', + 'menu.dashboard.workplace': 'Workplace', + 'menu.exception.403': '403', + 'menu.exception.404': '404', + 'menu.exception.500': '500', + 'menu.form': 'Form', + 'menu.form.basic-form': 'Basic Form', + 'menu.form.step-form': 'Step Form', + 'menu.form.step-form.info': 'Step Form(write transfer information)', + 'menu.form.step-form.confirm': 'Step Form(confirm transfer information)', + 'menu.form.step-form.result': 'Step Form(finished)', + 'menu.form.advanced-form': 'Advanced Form', + 'menu.list': 'List', + 'menu.list.table-list': 'Search Table', + 'menu.list.basic-list': 'Basic List', + 'menu.list.card-list': 'Card List', + 'menu.list.search-list': 'Search List', + 'menu.list.search-list.articles': 'Search List(articles)', + 'menu.list.search-list.projects': 'Search List(projects)', + 'menu.list.search-list.applications': 'Search List(applications)', + 'menu.profile': 'Profile', + 'menu.profile.basic': 'Basic Profile', + 'menu.profile.advanced': 'Advanced Profile', + 'menu.result': 'Result', + 'menu.result.success': 'Success', + 'menu.result.fail': 'Fail', + 'menu.exception': 'Exception', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': 'Trigger', + 'menu.account': 'Account', + 'menu.account.center': 'Account Center', + 'menu.account.settings': 'Account Settings', + 'menu.account.trigger': 'Trigger Error', + 'menu.account.logout': 'Logout', + 'menu.editor': 'Graphic Editor', + 'menu.editor.flow': 'Flow Editor', + 'menu.editor.mind': 'Mind Editor', + 'menu.editor.koni': 'Koni Editor', +}; diff --git a/react-ui/src/locales/en-US/pages.ts b/react-ui/src/locales/en-US/pages.ts new file mode 100644 index 00000000..58ff2c88 --- /dev/null +++ b/react-ui/src/locales/en-US/pages.ts @@ -0,0 +1,71 @@ +export default { + 'pages.layouts.userLayout.title': + 'Ant Design is the most influential web design specification in Xihu district', + 'pages.login.accountLogin.tab': 'Account Login', + 'pages.login.accountLogin.errorMessage': 'Incorrect username/password(admin/admin123)', + 'pages.login.failure': 'Login failed, please try again!', + 'pages.login.success': 'Login successful!', + 'pages.login.username.placeholder': 'Username: admin', + 'pages.login.username.required': 'Please input your username!', + 'pages.login.password.placeholder': 'Password: admin123', + 'pages.login.password.required': 'Please input your password!', + 'pages.login.phoneLogin.tab': 'Phone Login', + 'pages.login.phoneLogin.errorMessage': 'Verification Code Error', + 'pages.login.phoneNumber.placeholder': 'Phone Number', + 'pages.login.phoneNumber.required': 'Please input your phone number!', + 'pages.login.phoneNumber.invalid': 'Phone number is invalid!', + 'pages.login.captcha.placeholder': 'Verification Code', + 'pages.login.captcha.required': 'Please input verification code!', + 'pages.login.phoneLogin.getVerificationCode': 'Get Code', + 'pages.getCaptchaSecondText': 'sec(s)', + 'pages.login.rememberMe': 'Remember me', + 'pages.login.forgotPassword': 'Forgot Password ?', + 'pages.login.submit': 'Login', + 'pages.login.loginWith': 'Login with :', + 'pages.login.registerAccount': 'Register Account', + 'pages.welcome.link': 'Welcome', + 'pages.welcome.alertMessage': 'Faster and stronger heavy-duty components have been released.', + 'pages.admin.subPage.title': 'This page can only be viewed by Admin', + 'pages.admin.subPage.alertMessage': + 'Umi ui is now released, welcome to use npm run ui to start the experience.', + 'pages.searchTable.createForm.newRule': 'New Rule', + 'pages.searchTable.updateForm.ruleConfig': 'Rule configuration', + 'pages.searchTable.updateForm.basicConfig': 'Basic Information', + 'pages.searchTable.updateForm.ruleName.nameLabel': 'Rule Name', + 'pages.searchTable.updateForm.ruleName.nameRules': 'Please enter the rule name!', + 'pages.searchTable.updateForm.ruleDesc.descLabel': 'Rule Description', + 'pages.searchTable.updateForm.ruleDesc.descPlaceholder': 'Please enter at least five characters', + 'pages.searchTable.updateForm.ruleDesc.descRules': + 'Please enter a rule description of at least five characters!', + 'pages.searchTable.updateForm.ruleProps.title': 'Configure Properties', + 'pages.searchTable.updateForm.object': 'Monitoring Object', + 'pages.searchTable.updateForm.ruleProps.templateLabel': 'Rule Template', + 'pages.searchTable.updateForm.ruleProps.typeLabel': 'Rule Type', + 'pages.searchTable.updateForm.schedulingPeriod.title': 'Set Scheduling Period', + 'pages.searchTable.updateForm.schedulingPeriod.timeLabel': 'Starting Time', + 'pages.searchTable.updateForm.schedulingPeriod.timeRules': 'Please choose a start time!', + 'pages.searchTable.titleDesc': 'Description', + 'pages.searchTable.ruleName': 'Rule name is required', + 'pages.searchTable.titleCallNo': 'Number of Service Calls', + 'pages.searchTable.titleStatus': 'Status', + 'pages.searchTable.nameStatus.default': 'default', + 'pages.searchTable.nameStatus.running': 'running', + 'pages.searchTable.nameStatus.online': 'online', + 'pages.searchTable.nameStatus.abnormal': 'abnormal', + 'pages.searchTable.titleUpdatedAt': 'Last Scheduled at', + 'pages.searchTable.exception': 'Please enter the reason for the exception!', + 'pages.searchTable.titleOption': 'Option', + 'pages.searchTable.config': 'Configuration', + 'pages.searchTable.subscribeAlert': 'Subscribe to alerts', + 'pages.searchTable.title': 'Enquiry Form', + 'pages.searchTable.new': 'New', + 'pages.searchTable.edit': 'Edit', + 'pages.searchTable.delete': 'Delete', + 'pages.searchTable.export': 'Export', + 'pages.searchTable.chosen': 'chosen', + 'pages.searchTable.item': 'item', + 'pages.searchTable.totalServiceCalls': 'Total Number of Service Calls', + 'pages.searchTable.tenThousand': '0000', + 'pages.searchTable.batchDeletion': 'batch deletion', + 'pages.searchTable.batchApproval': 'batch approval', +}; diff --git a/react-ui/src/locales/en-US/pwa.ts b/react-ui/src/locales/en-US/pwa.ts new file mode 100644 index 00000000..ed8d199e --- /dev/null +++ b/react-ui/src/locales/en-US/pwa.ts @@ -0,0 +1,6 @@ +export default { + 'app.pwa.offline': 'You are offline now', + 'app.pwa.serviceworker.updated': 'New content is available', + 'app.pwa.serviceworker.updated.hint': 'Please press the "Refresh" button to reload current page', + 'app.pwa.serviceworker.updated.ok': 'Refresh', +}; diff --git a/react-ui/src/locales/en-US/settingDrawer.ts b/react-ui/src/locales/en-US/settingDrawer.ts new file mode 100644 index 00000000..a644905e --- /dev/null +++ b/react-ui/src/locales/en-US/settingDrawer.ts @@ -0,0 +1,31 @@ +export default { + 'app.setting.pagestyle': 'Page style setting', + 'app.setting.pagestyle.dark': 'Dark style', + 'app.setting.pagestyle.light': 'Light style', + 'app.setting.content-width': 'Content Width', + 'app.setting.content-width.fixed': 'Fixed', + 'app.setting.content-width.fluid': 'Fluid', + 'app.setting.themecolor': 'Theme Color', + 'app.setting.themecolor.dust': 'Dust Red', + 'app.setting.themecolor.volcano': 'Volcano', + 'app.setting.themecolor.sunset': 'Sunset Orange', + 'app.setting.themecolor.cyan': 'Cyan', + 'app.setting.themecolor.green': 'Polar Green', + 'app.setting.themecolor.daybreak': 'Daybreak Blue (default)', + 'app.setting.themecolor.geekblue': 'Geek Glue', + 'app.setting.themecolor.purple': 'Golden Purple', + 'app.setting.navigationmode': 'Navigation Mode', + 'app.setting.sidemenu': 'Side Menu Layout', + 'app.setting.topmenu': 'Top Menu Layout', + 'app.setting.fixedheader': 'Fixed Header', + 'app.setting.fixedsidebar': 'Fixed Sidebar', + 'app.setting.fixedsidebar.hint': 'Works on Side Menu Layout', + 'app.setting.hideheader': 'Hidden Header when scrolling', + 'app.setting.hideheader.hint': 'Works when Hidden Header is enabled', + 'app.setting.othersettings': 'Other Settings', + 'app.setting.weakmode': 'Weak Mode', + 'app.setting.copy': 'Copy Setting', + 'app.setting.copyinfo': 'copy success,please replace defaultSettings in src/models/setting.js', + 'app.setting.production.hint': + 'Setting panel shows in development environment only, please manually modify', +}; diff --git a/react-ui/src/locales/en-US/settings.ts b/react-ui/src/locales/en-US/settings.ts new file mode 100644 index 00000000..822dd003 --- /dev/null +++ b/react-ui/src/locales/en-US/settings.ts @@ -0,0 +1,60 @@ +export default { + 'app.settings.menuMap.basic': 'Basic Settings', + 'app.settings.menuMap.security': 'Security Settings', + 'app.settings.menuMap.binding': 'Account Binding', + 'app.settings.menuMap.notification': 'New Message Notification', + 'app.settings.basic.avatar': 'Avatar', + 'app.settings.basic.change-avatar': 'Change avatar', + 'app.settings.basic.email': 'Email', + 'app.settings.basic.email-message': 'Please input your email!', + 'app.settings.basic.nickname': 'Nickname', + 'app.settings.basic.nickname-message': 'Please input your Nickname!', + 'app.settings.basic.profile': 'Personal profile', + 'app.settings.basic.profile-message': 'Please input your personal profile!', + 'app.settings.basic.profile-placeholder': 'Brief introduction to yourself', + 'app.settings.basic.country': 'Country/Region', + 'app.settings.basic.country-message': 'Please input your country!', + 'app.settings.basic.geographic': 'Province or city', + 'app.settings.basic.geographic-message': 'Please input your geographic info!', + 'app.settings.basic.address': 'Street Address', + 'app.settings.basic.address-message': 'Please input your address!', + 'app.settings.basic.phone': 'Phone Number', + 'app.settings.basic.phone-message': 'Please input your phone!', + 'app.settings.basic.update': 'Update Information', + 'app.settings.security.strong': 'Strong', + 'app.settings.security.medium': 'Medium', + 'app.settings.security.weak': 'Weak', + 'app.settings.security.password': 'Account Password', + 'app.settings.security.password-description': 'Current password strength', + 'app.settings.security.phone': 'Security Phone', + 'app.settings.security.phone-description': 'Bound phone', + 'app.settings.security.question': 'Security Question', + 'app.settings.security.question-description': + 'The security question is not set, and the security policy can effectively protect the account security', + 'app.settings.security.email': 'Backup Email', + 'app.settings.security.email-description': 'Bound Email', + 'app.settings.security.mfa': 'MFA Device', + 'app.settings.security.mfa-description': + 'Unbound MFA device, after binding, can be confirmed twice', + 'app.settings.security.modify': 'Modify', + 'app.settings.security.set': 'Set', + 'app.settings.security.bind': 'Bind', + 'app.settings.binding.taobao': 'Binding Taobao', + 'app.settings.binding.taobao-description': 'Currently unbound Taobao account', + 'app.settings.binding.alipay': 'Binding Alipay', + 'app.settings.binding.alipay-description': 'Currently unbound Alipay account', + 'app.settings.binding.dingding': 'Binding DingTalk', + 'app.settings.binding.dingding-description': 'Currently unbound DingTalk account', + 'app.settings.binding.bind': 'Bind', + 'app.settings.notification.password': 'Account Password', + 'app.settings.notification.password-description': + 'Messages from other users will be notified in the form of a station letter', + 'app.settings.notification.messages': 'System Messages', + 'app.settings.notification.messages-description': + 'System messages will be notified in the form of a station letter', + 'app.settings.notification.todo': 'To-do Notification', + 'app.settings.notification.todo-description': + 'The to-do list will be notified in the form of a letter from the station', + 'app.settings.open': 'Open', + 'app.settings.close': 'Close', +}; diff --git a/react-ui/src/locales/zh-CN.ts b/react-ui/src/locales/zh-CN.ts new file mode 100644 index 00000000..ae9b1a77 --- /dev/null +++ b/react-ui/src/locales/zh-CN.ts @@ -0,0 +1,57 @@ +import app from './zh-CN/app'; +import component from './zh-CN/component'; +import globalHeader from './zh-CN/globalHeader'; +import sysmenu from './zh-CN/menu'; +import job from './zh-CN/monitor/job'; +import joblog from './zh-CN/monitor/job-log'; +import logininfor from './zh-CN/monitor/logininfor'; +import onlineUser from './zh-CN/monitor/onlineUser'; +import operlog from './zh-CN/monitor/operlog'; +import server from './zh-CN/monitor/server'; +import pages from './zh-CN/pages'; +import pwa from './zh-CN/pwa'; +import settingDrawer from './zh-CN/settingDrawer'; +import settings from './zh-CN/settings'; +import config from './zh-CN/system/config'; +import dept from './zh-CN/system/dept'; +import dict from './zh-CN/system/dict'; +import dictData from './zh-CN/system/dict-data'; +import menu from './zh-CN/system/menu'; +import notice from './zh-CN/system/notice'; +import post from './zh-CN/system/post'; +import role from './zh-CN/system/role'; +import user from './zh-CN/system/user'; + +export default { + 'navBar.lang': '语言', + 'layout.user.link.help': '帮助', + 'layout.user.link.privacy': '隐私', + 'layout.user.link.terms': '条款', + 'app.copyright.produced': '蚂蚁集团体验技术部出品', + 'app.preview.down.block': '下载此页面到本地项目', + 'app.welcome.link.fetch-blocks': '获取全部区块', + 'app.welcome.link.block-list': '基于 block 开发,快速构建标准页面', + ...app, + ...pages, + ...globalHeader, + ...sysmenu, + ...settingDrawer, + ...settings, + ...pwa, + ...component, + ...user, + ...menu, + ...dict, + ...dictData, + ...role, + ...dept, + ...post, + ...config, + ...notice, + ...operlog, + ...logininfor, + ...onlineUser, + ...job, + ...joblog, + ...server, +}; diff --git a/react-ui/src/locales/zh-CN/app.ts b/react-ui/src/locales/zh-CN/app.ts new file mode 100644 index 00000000..5be68e24 --- /dev/null +++ b/react-ui/src/locales/zh-CN/app.ts @@ -0,0 +1,23 @@ +export default { + 'app.docs.components.icon.search.placeholder': '在此搜索图标,点击图标可复制代码', + 'app.docs.components.icon.outlined': '线框风格', + 'app.docs.components.icon.filled': '实底风格', + 'app.docs.components.icon.two-tone': '双色风格', + 'app.docs.components.icon.category.direction': '方向性图标', + 'app.docs.components.icon.category.suggestion': '提示建议性图标', + 'app.docs.components.icon.category.editor': '编辑类图标', + 'app.docs.components.icon.category.data': '数据类图标', + 'app.docs.components.icon.category.other': '网站通用图标', + 'app.docs.components.icon.category.logo': '品牌和标识', + 'app.docs.components.icon.pic-searcher.intro': 'AI 截图搜索上线了,快来体验吧!🎉', + 'app.docs.components.icon.pic-searcher.title': '上传图片搜索图标', + 'app.docs.components.icon.pic-searcher.upload-text': '点击/拖拽/粘贴上传图片', + 'app.docs.components.icon.pic-searcher.upload-hint': + '我们会通过上传的图片进行匹配,得到最相似的图标', + 'app.docs.components.icon.pic-searcher.server-error': '识别服务暂不可用', + 'app.docs.components.icon.pic-searcher.matching': '匹配中...', + 'app.docs.components.icon.pic-searcher.modelloading': '神经网络模型加载中...', + 'app.docs.components.icon.pic-searcher.result-tip': '为您匹配到以下图标:', + 'app.docs.components.icon.pic-searcher.th-icon': '图标', + 'app.docs.components.icon.pic-searcher.th-score': '匹配度', +}; diff --git a/react-ui/src/locales/zh-CN/component.ts b/react-ui/src/locales/zh-CN/component.ts new file mode 100644 index 00000000..1f1feadb --- /dev/null +++ b/react-ui/src/locales/zh-CN/component.ts @@ -0,0 +1,5 @@ +export default { + 'component.tagSelect.expand': '展开', + 'component.tagSelect.collapse': '收起', + 'component.tagSelect.all': '全部', +}; diff --git a/react-ui/src/locales/zh-CN/globalHeader.ts b/react-ui/src/locales/zh-CN/globalHeader.ts new file mode 100644 index 00000000..9fd66a58 --- /dev/null +++ b/react-ui/src/locales/zh-CN/globalHeader.ts @@ -0,0 +1,17 @@ +export default { + 'component.globalHeader.search': '站内搜索', + 'component.globalHeader.search.example1': '搜索提示一', + 'component.globalHeader.search.example2': '搜索提示二', + 'component.globalHeader.search.example3': '搜索提示三', + 'component.globalHeader.help': '使用文档', + 'component.globalHeader.notification': '通知', + 'component.globalHeader.notification.empty': '你已查看所有通知', + 'component.globalHeader.message': '消息', + 'component.globalHeader.message.empty': '您已读完所有消息', + 'component.globalHeader.event': '待办', + 'component.globalHeader.event.empty': '你已完成所有待办', + 'component.noticeIcon.clear': '清空', + 'component.noticeIcon.cleared': '清空了', + 'component.noticeIcon.empty': '暂无数据', + 'component.noticeIcon.view-more': '查看更多', +}; diff --git a/react-ui/src/locales/zh-CN/menu.ts b/react-ui/src/locales/zh-CN/menu.ts new file mode 100644 index 00000000..fecb70a4 --- /dev/null +++ b/react-ui/src/locales/zh-CN/menu.ts @@ -0,0 +1,52 @@ +export default { + 'menu.welcome': '欢迎', + 'menu.more-blocks': '更多区块', + 'menu.home': '首页', + 'menu.admin': '管理页', + 'menu.admin.sub-page': '二级管理页', + 'menu.login': '登录', + 'menu.register': '注册', + 'menu.register-result': '注册结果', + 'menu.dashboard': 'Dashboard', + 'menu.dashboard.analysis': '分析页', + 'menu.dashboard.monitor': '监控页', + 'menu.dashboard.workplace': '工作台', + 'menu.exception.403': '403', + 'menu.exception.404': '404', + 'menu.exception.500': '500', + 'menu.form': '表单页', + 'menu.form.basic-form': '基础表单', + 'menu.form.step-form': '分步表单', + 'menu.form.step-form.info': '分步表单(填写转账信息)', + 'menu.form.step-form.confirm': '分步表单(确认转账信息)', + 'menu.form.step-form.result': '分步表单(完成)', + 'menu.form.advanced-form': '高级表单', + 'menu.list': '列表页', + 'menu.list.table-list': '查询表格', + 'menu.list.basic-list': '标准列表', + 'menu.list.card-list': '卡片列表', + 'menu.list.search-list': '搜索列表', + 'menu.list.search-list.articles': '搜索列表(文章)', + 'menu.list.search-list.projects': '搜索列表(项目)', + 'menu.list.search-list.applications': '搜索列表(应用)', + 'menu.profile': '详情页', + 'menu.profile.basic': '基础详情页', + 'menu.profile.advanced': '高级详情页', + 'menu.result': '结果页', + 'menu.result.success': '成功页', + 'menu.result.fail': '失败页', + 'menu.exception': '异常页', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': '触发错误', + 'menu.account': '个人页', + 'menu.account.center': '个人中心', + 'menu.account.settings': '个人设置', + 'menu.account.trigger': '触发报错', + 'menu.account.logout': '退出登录', + 'menu.editor': '图形编辑器', + 'menu.editor.flow': '流程编辑器', + 'menu.editor.mind': '脑图编辑器', + 'menu.editor.koni': '拓扑编辑器', +}; diff --git a/react-ui/src/locales/zh-CN/monitor/job-log.ts b/react-ui/src/locales/zh-CN/monitor/job-log.ts new file mode 100644 index 00000000..5c038599 --- /dev/null +++ b/react-ui/src/locales/zh-CN/monitor/job-log.ts @@ -0,0 +1,18 @@ +/** + * 定时任务调度日志 + * + * @author whiteshader + * @date 2023-02-07 + */ + +export default { + 'monitor.job.log.title': '定时任务调度日志', + 'monitor.job.log.job_log_id': '任务日志编号', + 'monitor.job.log.job_name': '任务名称', + 'monitor.job.log.job_group': '任务组名', + 'monitor.job.log.invoke_target': '调用方法', + 'monitor.job.log.job_message': '日志信息', + 'monitor.job.log.status': '执行状态', + 'monitor.job.log.exception_info': '异常信息', + 'monitor.job.log.create_time': '创建时间', +}; diff --git a/react-ui/src/locales/zh-CN/monitor/job.ts b/react-ui/src/locales/zh-CN/monitor/job.ts new file mode 100644 index 00000000..866ad8f0 --- /dev/null +++ b/react-ui/src/locales/zh-CN/monitor/job.ts @@ -0,0 +1,25 @@ +/** + * 定时任务调度 + * + * @author whiteshader@163.com + * @date 2023-02-07 + */ + +export default { + 'monitor.job.title': '定时任务调度', + 'monitor.job.job_id': '任务编号', + 'monitor.job.job_name': '任务名称', + 'monitor.job.job_group': '任务组名', + 'monitor.job.invoke_target': '调用方法', + 'monitor.job.cron_expression': 'cron执行表达式', + 'monitor.job.misfire_policy': '执行策略', + 'monitor.job.concurrent': '是否并发执行', + 'monitor.job.next_valid_time': '下次执行时间', + 'monitor.job.status': '状态', + 'monitor.job.create_by': '创建者', + 'monitor.job.create_time': '创建时间', + 'monitor.job.update_by': '更新者', + 'monitor.job.update_time': '更新时间', + 'monitor.job.remark': '备注信息', + 'monitor.job.detail': '任务详情', +}; diff --git a/react-ui/src/locales/zh-CN/monitor/logininfor.ts b/react-ui/src/locales/zh-CN/monitor/logininfor.ts new file mode 100644 index 00000000..10369ac8 --- /dev/null +++ b/react-ui/src/locales/zh-CN/monitor/logininfor.ts @@ -0,0 +1,13 @@ +export default { + 'monitor.logininfor.title': '系统访问记录', + 'monitor.logininfor.info_id': '访问编号', + 'monitor.logininfor.user_name': '用户账号', + 'monitor.logininfor.ipaddr': '登录IP地址', + 'monitor.logininfor.login_location': '登录地点', + 'monitor.logininfor.browser': '浏览器类型', + 'monitor.logininfor.os': '操作系统', + 'monitor.logininfor.status': '登录状态', + 'monitor.logininfor.msg': '提示消息', + 'monitor.logininfor.login_time': '访问时间', + 'monitor.logininfor.unlock': '解锁', +}; diff --git a/react-ui/src/locales/zh-CN/monitor/onlineUser.ts b/react-ui/src/locales/zh-CN/monitor/onlineUser.ts new file mode 100644 index 00000000..fd2377a3 --- /dev/null +++ b/react-ui/src/locales/zh-CN/monitor/onlineUser.ts @@ -0,0 +1,19 @@ +/* * + * + * @author whiteshader@163.com + * @datetime 2021/09/16 + * + * */ + +export default { + 'monitor.online.user.id': '编号', + 'monitor.online.user.token_id': '会话编号', + 'monitor.online.user.user_name': '会话编号', + 'monitor.online.user.ipaddr': '登录IP地址', + 'monitor.online.user.login_location': '登录地点', + 'monitor.online.user.browser': '浏览器类型', + 'monitor.online.user.os': '操作系统', + 'monitor.online.user.dept_name': '部门', + 'monitor.online.user.login_time': '访问时间', + 'monitor.online.user.force_logout': '强制退出', +}; diff --git a/react-ui/src/locales/zh-CN/monitor/operlog.ts b/react-ui/src/locales/zh-CN/monitor/operlog.ts new file mode 100644 index 00000000..6faf1c85 --- /dev/null +++ b/react-ui/src/locales/zh-CN/monitor/operlog.ts @@ -0,0 +1,19 @@ +export default { + 'monitor.operlog.title': '操作日志记录', + 'monitor.operlog.oper_id': '日志主键', + 'monitor.operlog.business_type': '业务类型', + 'monitor.operlog.method': '方法名称', + 'monitor.operlog.request_method': '请求方式', + 'monitor.operlog.operator_type': '操作类别', + 'monitor.operlog.oper_name': '操作人员', + 'monitor.operlog.dept_name': '部门名称', + 'monitor.operlog.oper_url': '请求URL', + 'monitor.operlog.oper_ip': '主机地址', + 'monitor.operlog.oper_location': '操作地点', + 'monitor.operlog.oper_param': '请求参数', + 'monitor.operlog.json_result': '返回参数', + 'monitor.operlog.status': '操作状态', + 'monitor.operlog.error_msg': '错误消息', + 'monitor.operlog.oper_time': '操作时间', + 'monitor.operlog.module': '操作模块', +}; diff --git a/react-ui/src/locales/zh-CN/monitor/server.ts b/react-ui/src/locales/zh-CN/monitor/server.ts new file mode 100644 index 00000000..33210f65 --- /dev/null +++ b/react-ui/src/locales/zh-CN/monitor/server.ts @@ -0,0 +1,26 @@ +/* * + * + * @author whiteshader@163.com + * @datetime 2021/09/16 + * + * */ + +export default { + 'monitor.server.cpu.cpuNum': '核心数', + 'monitor.server.cpu.total': '总使用率', + 'monitor.server.cpu.sys': '系统使用率', + 'monitor.server.cpu.used': '用户使用率', + 'monitor.server.cpu.wait': 'IO等待', + 'monitor.server.cpu.free': '当前空闲率', + 'monitor.server.mem.total': '总内存', + 'monitor.server.mem.used': '已用内存', + 'monitor.server.mem.free': '剩余内存', + 'monitor.server.mem.usage': '使用率', + 'monitor.server.disk.dirName': '盘符路径', + 'monitor.server.disk.sysTypeName': '文件系统', + 'monitor.server.disk.typeName': '盘符类型', + 'monitor.server.disk.total': '总大小', + 'monitor.server.disk.free': '可用大小', + 'monitor.server.disk.used': '已用大小', + 'monitor.server.disk.usage': '使用率', +}; diff --git a/react-ui/src/locales/zh-CN/pages.ts b/react-ui/src/locales/zh-CN/pages.ts new file mode 100644 index 00000000..ed9afc5e --- /dev/null +++ b/react-ui/src/locales/zh-CN/pages.ts @@ -0,0 +1,71 @@ +export default { + 'pages.layouts.userLayout.title': 'Ant Design 是西湖区最具影响力的 Web 设计规范', + 'pages.login.accountLogin.tab': '账户密码登录', + 'pages.login.accountLogin.errorMessage': '错误的用户名和密码', + 'pages.login.failure': '登录失败,请重试!', + 'pages.login.success': '登录成功!', + 'pages.login.username.placeholder': '用户名', + 'pages.login.username.required': '用户名是必填项!', + 'pages.login.password.placeholder': '密码', + 'pages.login.password.required': '密码是必填项!', + 'pages.login.phoneLogin.tab': '手机号登录', + 'pages.login.phoneLogin.errorMessage': '验证码错误', + 'pages.login.phoneNumber.placeholder': '请输入手机号!', + 'pages.login.phoneNumber.required': '手机号是必填项!', + 'pages.login.phoneNumber.invalid': '不合法的手机号!', + 'pages.login.captcha.placeholder': '请输入验证码!', + 'pages.login.captcha.required': '验证码是必填项!', + 'pages.login.phoneLogin.getVerificationCode': '获取验证码', + 'pages.getCaptchaSecondText': '秒后重新获取', + 'pages.login.rememberMe': '记住密码', + 'pages.login.forgotPassword': '忘记密码 ?', + 'pages.login.submit': '登录', + 'pages.login.loginWith': '其他登录方式 :', + 'pages.login.registerAccount': '注册账户', + 'pages.goback': '返回', + 'pages.welcome.link': '欢迎使用', + 'pages.welcome.alertMessage': '更快更强的重型组件,已经发布。', + 'pages.admin.subPage.title': ' 这个页面只有 admin 权限才能查看', + 'pages.admin.subPage.alertMessage': 'umi ui 现已发布,欢迎使用 npm run ui 启动体验。', + 'pages.searchTable.createForm.newRule': '新建规则', + 'pages.searchTable.updateForm.ruleConfig': '规则配置', + 'pages.searchTable.updateForm.basicConfig': '基本信息', + 'pages.searchTable.updateForm.ruleName.nameLabel': '规则名称', + 'pages.searchTable.updateForm.ruleName.nameRules': '请输入规则名称!', + 'pages.searchTable.updateForm.ruleDesc.descLabel': '规则描述', + 'pages.searchTable.updateForm.ruleDesc.descPlaceholder': '请输入至少五个字符', + 'pages.searchTable.updateForm.ruleDesc.descRules': '请输入至少五个字符的规则描述!', + 'pages.searchTable.updateForm.ruleProps.title': '配置规则属性', + 'pages.searchTable.updateForm.object': '监控对象', + 'pages.searchTable.updateForm.ruleProps.templateLabel': '规则模板', + 'pages.searchTable.updateForm.ruleProps.typeLabel': '规则类型', + 'pages.searchTable.updateForm.schedulingPeriod.title': '设定调度周期', + 'pages.searchTable.updateForm.schedulingPeriod.timeLabel': '开始时间', + 'pages.searchTable.updateForm.schedulingPeriod.timeRules': '请选择开始时间!', + 'pages.searchTable.updateForm.pleaseInput': '请输入', + 'pages.searchTable.titleDesc': '描述', + 'pages.searchTable.ruleName': '规则名称为必填项', + 'pages.searchTable.titleCallNo': '服务调用次数', + 'pages.searchTable.titleStatus': '状态', + 'pages.searchTable.nameStatus.default': '关闭', + 'pages.searchTable.nameStatus.running': '运行中', + 'pages.searchTable.nameStatus.online': '已上线', + 'pages.searchTable.nameStatus.abnormal': '异常', + 'pages.searchTable.titleUpdatedAt': '上次调度时间', + 'pages.searchTable.exception': '请输入异常原因!', + 'pages.searchTable.titleOption': '操作', + 'pages.searchTable.config': '配置', + 'pages.searchTable.subscribeAlert': '订阅警报', + 'pages.searchTable.title': '查询表格', + 'pages.searchTable.new': '新建', + 'pages.searchTable.edit': '编辑', + 'pages.searchTable.delete': '删除', + 'pages.searchTable.cleanAll': '清空', + 'pages.searchTable.export': '导出', + 'pages.searchTable.chosen': '已选择', + 'pages.searchTable.item': '项', + 'pages.searchTable.totalServiceCalls': '服务调用次数总计', + 'pages.searchTable.tenThousand': '万', + 'pages.searchTable.batchDeletion': '批量删除', + 'pages.searchTable.batchApproval': '批量审批', +}; diff --git a/react-ui/src/locales/zh-CN/pwa.ts b/react-ui/src/locales/zh-CN/pwa.ts new file mode 100644 index 00000000..e9504849 --- /dev/null +++ b/react-ui/src/locales/zh-CN/pwa.ts @@ -0,0 +1,6 @@ +export default { + 'app.pwa.offline': '当前处于离线状态', + 'app.pwa.serviceworker.updated': '有新内容', + 'app.pwa.serviceworker.updated.hint': '请点击“刷新”按钮或者手动刷新页面', + 'app.pwa.serviceworker.updated.ok': '刷新', +}; diff --git a/react-ui/src/locales/zh-CN/settingDrawer.ts b/react-ui/src/locales/zh-CN/settingDrawer.ts new file mode 100644 index 00000000..3f44958e --- /dev/null +++ b/react-ui/src/locales/zh-CN/settingDrawer.ts @@ -0,0 +1,31 @@ +export default { + 'app.setting.pagestyle': '整体风格设置', + 'app.setting.pagestyle.dark': '暗色菜单风格', + 'app.setting.pagestyle.light': '亮色菜单风格', + 'app.setting.content-width': '内容区域宽度', + 'app.setting.content-width.fixed': '定宽', + 'app.setting.content-width.fluid': '流式', + 'app.setting.themecolor': '主题色', + 'app.setting.themecolor.dust': '薄暮', + 'app.setting.themecolor.volcano': '火山', + 'app.setting.themecolor.sunset': '日暮', + 'app.setting.themecolor.cyan': '明青', + 'app.setting.themecolor.green': '极光绿', + 'app.setting.themecolor.daybreak': '拂晓蓝(默认)', + 'app.setting.themecolor.geekblue': '极客蓝', + 'app.setting.themecolor.purple': '酱紫', + 'app.setting.navigationmode': '导航模式', + 'app.setting.sidemenu': '侧边菜单布局', + 'app.setting.topmenu': '顶部菜单布局', + 'app.setting.fixedheader': '固定 Header', + 'app.setting.fixedsidebar': '固定侧边菜单', + 'app.setting.fixedsidebar.hint': '侧边菜单布局时可配置', + 'app.setting.hideheader': '下滑时隐藏 Header', + 'app.setting.hideheader.hint': '固定 Header 时可配置', + 'app.setting.othersettings': '其他设置', + 'app.setting.weakmode': '色弱模式', + 'app.setting.copy': '拷贝设置', + 'app.setting.copyinfo': '拷贝成功,请到 config/defaultSettings.js 中替换默认配置', + 'app.setting.production.hint': + '配置栏只在开发环境用于预览,生产环境不会展现,请拷贝后手动修改配置文件', +}; diff --git a/react-ui/src/locales/zh-CN/settings.ts b/react-ui/src/locales/zh-CN/settings.ts new file mode 100644 index 00000000..df8af434 --- /dev/null +++ b/react-ui/src/locales/zh-CN/settings.ts @@ -0,0 +1,55 @@ +export default { + 'app.settings.menuMap.basic': '基本设置', + 'app.settings.menuMap.security': '安全设置', + 'app.settings.menuMap.binding': '账号绑定', + 'app.settings.menuMap.notification': '新消息通知', + 'app.settings.basic.avatar': '头像', + 'app.settings.basic.change-avatar': '更换头像', + 'app.settings.basic.email': '邮箱', + 'app.settings.basic.email-message': '请输入您的邮箱!', + 'app.settings.basic.nickname': '昵称', + 'app.settings.basic.nickname-message': '请输入您的昵称!', + 'app.settings.basic.profile': '个人简介', + 'app.settings.basic.profile-message': '请输入个人简介!', + 'app.settings.basic.profile-placeholder': '个人简介', + 'app.settings.basic.country': '国家/地区', + 'app.settings.basic.country-message': '请输入您的国家或地区!', + 'app.settings.basic.geographic': '所在省市', + 'app.settings.basic.geographic-message': '请输入您的所在省市!', + 'app.settings.basic.address': '街道地址', + 'app.settings.basic.address-message': '请输入您的街道地址!', + 'app.settings.basic.phone': '联系电话', + 'app.settings.basic.phone-message': '请输入您的联系电话!', + 'app.settings.basic.update': '更新基本信息', + 'app.settings.security.strong': '强', + 'app.settings.security.medium': '中', + 'app.settings.security.weak': '弱', + 'app.settings.security.password': '账户密码', + 'app.settings.security.password-description': '当前密码强度', + 'app.settings.security.phone': '密保手机', + 'app.settings.security.phone-description': '已绑定手机', + 'app.settings.security.question': '密保问题', + 'app.settings.security.question-description': '未设置密保问题,密保问题可有效保护账户安全', + 'app.settings.security.email': '备用邮箱', + 'app.settings.security.email-description': '已绑定邮箱', + 'app.settings.security.mfa': 'MFA 设备', + 'app.settings.security.mfa-description': '未绑定 MFA 设备,绑定后,可以进行二次确认', + 'app.settings.security.modify': '修改', + 'app.settings.security.set': '设置', + 'app.settings.security.bind': '绑定', + 'app.settings.binding.taobao': '绑定淘宝', + 'app.settings.binding.taobao-description': '当前未绑定淘宝账号', + 'app.settings.binding.alipay': '绑定支付宝', + 'app.settings.binding.alipay-description': '当前未绑定支付宝账号', + 'app.settings.binding.dingding': '绑定钉钉', + 'app.settings.binding.dingding-description': '当前未绑定钉钉账号', + 'app.settings.binding.bind': '绑定', + 'app.settings.notification.password': '账户密码', + 'app.settings.notification.password-description': '其他用户的消息将以站内信的形式通知', + 'app.settings.notification.messages': '系统消息', + 'app.settings.notification.messages-description': '系统消息将以站内信的形式通知', + 'app.settings.notification.todo': '待办任务', + 'app.settings.notification.todo-description': '待办任务将以站内信的形式通知', + 'app.settings.open': '开', + 'app.settings.close': '关', +}; diff --git a/react-ui/src/locales/zh-CN/system/config.ts b/react-ui/src/locales/zh-CN/system/config.ts new file mode 100644 index 00000000..8c230928 --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/config.ts @@ -0,0 +1,14 @@ +export default { + 'system.config.title': '参数配置', + 'system.config.config_id': '参数主键', + 'system.config.config_name': '参数名称', + 'system.config.config_key': '参数键名', + 'system.config.config_value': '参数键值', + 'system.config.config_type': '系统内置', + 'system.config.create_by': '创建者', + 'system.config.create_time': '创建时间', + 'system.config.update_by': '更新者', + 'system.config.update_time': '更新时间', + 'system.config.remark': '备注', + 'system.config.refreshCache': '刷新缓存', +}; diff --git a/react-ui/src/locales/zh-CN/system/dept.ts b/react-ui/src/locales/zh-CN/system/dept.ts new file mode 100644 index 00000000..534f3119 --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/dept.ts @@ -0,0 +1,18 @@ +export default { + 'system.dept.title': '部门', + 'system.dept.dept_id': '部门id', + 'system.dept.parent_id': '父部门id', + 'system.dept.parent_dept': '上级部门', + 'system.dept.ancestors': '祖级列表', + 'system.dept.dept_name': '部门名称', + 'system.dept.order_num': '显示顺序', + 'system.dept.leader': '负责人', + 'system.dept.phone': '联系电话', + 'system.dept.email': '邮箱', + 'system.dept.status': '部门状态', + 'system.dept.del_flag': '删除标志', + 'system.dept.create_by': '创建者', + 'system.dept.create_time': '创建时间', + 'system.dept.update_by': '更新者', + 'system.dept.update_time': '更新时间', +}; diff --git a/react-ui/src/locales/zh-CN/system/dict-data.ts b/react-ui/src/locales/zh-CN/system/dict-data.ts new file mode 100644 index 00000000..db2c742e --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/dict-data.ts @@ -0,0 +1,17 @@ +export default { + 'system.dict.data.title': '字典数据', + 'system.dict.data.dict_code': '字典编码', + 'system.dict.data.dict_sort': '字典排序', + 'system.dict.data.dict_label': '字典标签', + 'system.dict.data.dict_value': '字典键值', + 'system.dict.data.dict_type': '字典类型', + 'system.dict.data.css_class': '样式属性', + 'system.dict.data.list_class': '回显样式', + 'system.dict.data.is_default': '是否默认', + 'system.dict.data.status': '状态', + 'system.dict.data.create_by': '创建者', + 'system.dict.data.create_time': '创建时间', + 'system.dict.data.update_by': '更新者', + 'system.dict.data.update_time': '更新时间', + 'system.dict.data.remark': '备注', +}; diff --git a/react-ui/src/locales/zh-CN/system/dict.ts b/react-ui/src/locales/zh-CN/system/dict.ts new file mode 100644 index 00000000..cf00f662 --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/dict.ts @@ -0,0 +1,12 @@ +export default { + 'system.dict.title': '字典类型', + 'system.dict.dict_id': '字典主键', + 'system.dict.dict_name': '字典名称', + 'system.dict.dict_type': '字典类型', + 'system.dict.status': '状态', + 'system.dict.create_by': '创建者', + 'system.dict.create_time': '创建时间', + 'system.dict.update_by': '更新者', + 'system.dict.update_time': '更新时间', + 'system.dict.remark': '备注', +}; diff --git a/react-ui/src/locales/zh-CN/system/menu.ts b/react-ui/src/locales/zh-CN/system/menu.ts new file mode 100644 index 00000000..163c2cec --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/menu.ts @@ -0,0 +1,22 @@ +export default { + 'system.menu.title': '菜单权限', + 'system.menu.menu_id': '菜单编号', + 'system.menu.menu_name': '菜单名称', + 'system.menu.parent_id': '上级菜单', + 'system.menu.order_num': '显示顺序', + 'system.menu.path': '路由地址', + 'system.menu.component': '组件路径', + 'system.menu.query': '路由参数', + 'system.menu.is_frame': '是否为外链', + 'system.menu.is_cache': '是否缓存', + 'system.menu.menu_type': '菜单类型', + 'system.menu.visible': '显示状态', + 'system.menu.status': '菜单状态', + 'system.menu.perms': '权限标识', + 'system.menu.icon': '菜单图标', + 'system.menu.create_by': '创建者', + 'system.menu.create_time': '创建时间', + 'system.menu.update_by': '更新者', + 'system.menu.update_time': '更新时间', + 'system.menu.remark': '备注', +}; diff --git a/react-ui/src/locales/zh-CN/system/notice.ts b/react-ui/src/locales/zh-CN/system/notice.ts new file mode 100644 index 00000000..b5c94982 --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/notice.ts @@ -0,0 +1,13 @@ +export default { + 'system.notice.title': '通知公告', + 'system.notice.notice_id': '公告编号', + 'system.notice.notice_title': '公告标题', + 'system.notice.notice_type': '公告类型', + 'system.notice.notice_content': '公告内容', + 'system.notice.status': '公告状态', + 'system.notice.create_by': '创建者', + 'system.notice.create_time': '创建时间', + 'system.notice.update_by': '更新者', + 'system.notice.update_time': '更新时间', + 'system.notice.remark': '备注', +}; diff --git a/react-ui/src/locales/zh-CN/system/post.ts b/react-ui/src/locales/zh-CN/system/post.ts new file mode 100644 index 00000000..c230a6c0 --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/post.ts @@ -0,0 +1,13 @@ +export default { + 'system.post.title': '岗位信息', + 'system.post.post_id': '岗位编号', + 'system.post.post_code': '岗位编码', + 'system.post.post_name': '岗位名称', + 'system.post.post_sort': '显示顺序', + 'system.post.status': '状态', + 'system.post.create_by': '创建者', + 'system.post.create_time': '创建时间', + 'system.post.update_by': '更新者', + 'system.post.update_time': '更新时间', + 'system.post.remark': '备注', +}; diff --git a/react-ui/src/locales/zh-CN/system/role.ts b/react-ui/src/locales/zh-CN/system/role.ts new file mode 100644 index 00000000..3c3d10e1 --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/role.ts @@ -0,0 +1,21 @@ +export default { + 'system.role.title': '角色信息', + 'system.role.role_id': '角色编号', + 'system.role.role_name': '角色名称', + 'system.role.role_key': '权限字符', + 'system.role.role_sort': '显示顺序', + 'system.role.data_scope': '数据范围', + 'system.role.menu_check_strictly': '菜单树选择项是否关联显示', + 'system.role.dept_check_strictly': '部门树选择项是否关联显示', + 'system.role.status': '角色状态', + 'system.role.del_flag': '删除标志', + 'system.role.create_by': '创建者', + 'system.role.create_time': '创建时间', + 'system.role.update_by': '更新者', + 'system.role.update_time': '更新时间', + 'system.role.remark': '备注', + 'system.role.auth': '菜单权限', + 'system.role.auth.user': '选择用户', + 'system.role.auth.addUser': '添加用户', + 'system.role.auth.cancelAll': '批量取消授权', +}; diff --git a/react-ui/src/locales/zh-CN/system/user.ts b/react-ui/src/locales/zh-CN/system/user.ts new file mode 100644 index 00000000..66a9f8b1 --- /dev/null +++ b/react-ui/src/locales/zh-CN/system/user.ts @@ -0,0 +1,31 @@ +export default { + 'system.user.title': '用户信息', + 'system.user.user_id': '用户编号', + 'system.user.dept_name': '部门', + 'system.user.user_name': '用户账号', + 'system.user.nick_name': '用户昵称', + 'system.user.user_type': '用户类型', + 'system.user.email': '用户邮箱', + 'system.user.phonenumber': '手机号码', + 'system.user.sex': '用户性别', + 'system.user.avatar': '头像地址', + 'system.user.password': '密码', + 'system.user.status': '帐号状态', + 'system.user.del_flag': '删除标志', + 'system.user.login_ip': '最后登录IP', + 'system.user.login_date': '最后登录时间', + 'system.user.create_by': '创建者', + 'system.user.create_time': '创建时间', + 'system.user.update_by': '更新者', + 'system.user.update_time': '更新时间', + 'system.user.remark': '备注', + 'system.user.post': '岗位', + 'system.user.role': '角色', + 'system.user.auth.role': '分配角色', + 'system.user.reset.password': '密码重置', + 'system.user.modify_info': '编辑用户信息', + 'system.user.old_password': '旧密码', + 'system.user.new_password': '新密码', + 'system.user.confirm_password': '确认密码', + 'system.user.modify_avatar': '修改头像', +}; diff --git a/react-ui/src/locales/zh-TW.ts b/react-ui/src/locales/zh-TW.ts new file mode 100644 index 00000000..6ad5f931 --- /dev/null +++ b/react-ui/src/locales/zh-TW.ts @@ -0,0 +1,20 @@ +import component from './zh-TW/component'; +import globalHeader from './zh-TW/globalHeader'; +import menu from './zh-TW/menu'; +import pwa from './zh-TW/pwa'; +import settingDrawer from './zh-TW/settingDrawer'; +import settings from './zh-TW/settings'; + +export default { + 'navBar.lang': '語言', + 'layout.user.link.help': '幫助', + 'layout.user.link.privacy': '隱私', + 'layout.user.link.terms': '條款', + 'app.preview.down.block': '下載此頁面到本地項目', + ...globalHeader, + ...menu, + ...settingDrawer, + ...settings, + ...pwa, + ...component, +}; diff --git a/react-ui/src/locales/zh-TW/component.ts b/react-ui/src/locales/zh-TW/component.ts new file mode 100644 index 00000000..ba48e299 --- /dev/null +++ b/react-ui/src/locales/zh-TW/component.ts @@ -0,0 +1,5 @@ +export default { + 'component.tagSelect.expand': '展開', + 'component.tagSelect.collapse': '收起', + 'component.tagSelect.all': '全部', +}; diff --git a/react-ui/src/locales/zh-TW/globalHeader.ts b/react-ui/src/locales/zh-TW/globalHeader.ts new file mode 100644 index 00000000..ed584518 --- /dev/null +++ b/react-ui/src/locales/zh-TW/globalHeader.ts @@ -0,0 +1,17 @@ +export default { + 'component.globalHeader.search': '站內搜索', + 'component.globalHeader.search.example1': '搜索提示壹', + 'component.globalHeader.search.example2': '搜索提示二', + 'component.globalHeader.search.example3': '搜索提示三', + 'component.globalHeader.help': '使用手冊', + 'component.globalHeader.notification': '通知', + 'component.globalHeader.notification.empty': '妳已查看所有通知', + 'component.globalHeader.message': '消息', + 'component.globalHeader.message.empty': '您已讀完所有消息', + 'component.globalHeader.event': '待辦', + 'component.globalHeader.event.empty': '妳已完成所有待辦', + 'component.noticeIcon.clear': '清空', + 'component.noticeIcon.cleared': '清空了', + 'component.noticeIcon.empty': '暫無資料', + 'component.noticeIcon.view-more': '查看更多', +}; diff --git a/react-ui/src/locales/zh-TW/menu.ts b/react-ui/src/locales/zh-TW/menu.ts new file mode 100644 index 00000000..0ef54c95 --- /dev/null +++ b/react-ui/src/locales/zh-TW/menu.ts @@ -0,0 +1,52 @@ +export default { + 'menu.welcome': '歡迎', + 'menu.more-blocks': '更多區塊', + 'menu.home': '首頁', + 'menu.admin': '权限', + 'menu.admin.sub-page': '二级管理页', + 'menu.login': '登錄', + 'menu.register': '註冊', + 'menu.register-result': '註冊結果', + 'menu.dashboard': 'Dashboard', + 'menu.dashboard.analysis': '分析頁', + 'menu.dashboard.monitor': '監控頁', + 'menu.dashboard.workplace': '工作臺', + 'menu.exception.403': '403', + 'menu.exception.404': '404', + 'menu.exception.500': '500', + 'menu.form': '表單頁', + 'menu.form.basic-form': '基礎表單', + 'menu.form.step-form': '分步表單', + 'menu.form.step-form.info': '分步表單(填寫轉賬信息)', + 'menu.form.step-form.confirm': '分步表單(確認轉賬信息)', + 'menu.form.step-form.result': '分步表單(完成)', + 'menu.form.advanced-form': '高級表單', + 'menu.list': '列表頁', + 'menu.list.table-list': '查詢表格', + 'menu.list.basic-list': '標淮列表', + 'menu.list.card-list': '卡片列表', + 'menu.list.search-list': '搜索列表', + 'menu.list.search-list.articles': '搜索列表(文章)', + 'menu.list.search-list.projects': '搜索列表(項目)', + 'menu.list.search-list.applications': '搜索列表(應用)', + 'menu.profile': '詳情頁', + 'menu.profile.basic': '基礎詳情頁', + 'menu.profile.advanced': '高級詳情頁', + 'menu.result': '結果頁', + 'menu.result.success': '成功頁', + 'menu.result.fail': '失敗頁', + 'menu.exception': '异常页', + 'menu.exception.not-permission': '403', + 'menu.exception.not-find': '404', + 'menu.exception.server-error': '500', + 'menu.exception.trigger': '触发错误', + 'menu.account': '個人頁', + 'menu.account.center': '個人中心', + 'menu.account.settings': '個人設置', + 'menu.account.trigger': '觸發報錯', + 'menu.account.logout': '退出登錄', + 'menu.editor': '圖形編輯器', + 'menu.editor.flow': '流程編輯器', + 'menu.editor.mind': '腦圖編輯器', + 'menu.editor.koni': '拓撲編輯器', +}; diff --git a/react-ui/src/locales/zh-TW/pages.ts b/react-ui/src/locales/zh-TW/pages.ts new file mode 100644 index 00000000..b3e37ef5 --- /dev/null +++ b/react-ui/src/locales/zh-TW/pages.ts @@ -0,0 +1,68 @@ +export default { + 'pages.layouts.userLayout.title': 'Ant Design 是西湖區最具影響力的 Web 設計規範', + 'pages.login.accountLogin.tab': '賬戶密碼登錄', + 'pages.login.accountLogin.errorMessage': '錯誤的用戶名和密碼', + 'pages.login.failure': '登錄失敗,請重試!', + 'pages.login.success': '登錄成功!', + 'pages.login.username.placeholder': '用戶名', + 'pages.login.username.required': '用戶名是必填項!', + 'pages.login.password.placeholder': '密碼', + 'pages.login.password.required': '密碼是必填項!', + 'pages.login.phoneLogin.tab': '手機號登錄', + 'pages.login.phoneLogin.errorMessage': '驗證碼錯誤', + 'pages.login.phoneNumber.placeholder': '請輸入手機號!', + 'pages.login.phoneNumber.required': '手機號是必填項!', + 'pages.login.phoneNumber.invalid': '不合法的手機號!', + 'pages.login.captcha.placeholder': '請輸入驗證碼!', + 'pages.login.captcha.required': '驗證碼是必填項!', + 'pages.login.phoneLogin.getVerificationCode': '獲取驗證碼', + 'pages.getCaptchaSecondText': '秒後重新獲取', + 'pages.login.rememberMe': '自動登錄', + 'pages.login.forgotPassword': '忘記密碼 ?', + 'pages.login.submit': '登錄', + 'pages.login.loginWith': '其他登錄方式 :', + 'pages.login.registerAccount': '註冊賬戶', + 'pages.welcome.link': '歡迎使用', + 'pages.welcome.alertMessage': '更快更強的重型組件,已經發布。', + 'pages.admin.subPage.title': '這個頁面只有 admin 權限才能查看', + 'pages.admin.subPage.alertMessage': 'umi ui 現已發佈,歡迎使用 npm run ui 啓動體驗。', + 'pages.searchTable.createForm.newRule': '新建規則', + 'pages.searchTable.updateForm.ruleConfig': '規則配置', + 'pages.searchTable.updateForm.basicConfig': '基本信息', + 'pages.searchTable.updateForm.ruleName.nameLabel': '規則名稱', + 'pages.searchTable.updateForm.ruleName.nameRules': '請輸入規則名稱!', + 'pages.searchTable.updateForm.ruleDesc.descLabel': '規則描述', + 'pages.searchTable.updateForm.ruleDesc.descPlaceholder': '請輸入至少五個字符', + 'pages.searchTable.updateForm.ruleDesc.descRules': '請輸入至少五個字符的規則描述!', + 'pages.searchTable.updateForm.ruleProps.title': '配置規則屬性', + 'pages.searchTable.updateForm.object': '監控對象', + 'pages.searchTable.updateForm.ruleProps.templateLabel': '規則模板', + 'pages.searchTable.updateForm.ruleProps.typeLabel': '規則類型', + 'pages.searchTable.updateForm.schedulingPeriod.title': '設定調度週期', + 'pages.searchTable.updateForm.schedulingPeriod.timeLabel': '開始時間', + 'pages.searchTable.updateForm.schedulingPeriod.timeRules': '請選擇開始時間!', + 'pages.searchTable.titleDesc': '描述', + 'pages.searchTable.ruleName': '規則名稱爲必填項', + 'pages.searchTable.titleCallNo': '服務調用次數', + 'pages.searchTable.titleStatus': '狀態', + 'pages.searchTable.nameStatus.default': '關閉', + 'pages.searchTable.nameStatus.running': '運行中', + 'pages.searchTable.nameStatus.online': '已上線', + 'pages.searchTable.nameStatus.abnormal': '異常', + 'pages.searchTable.titleUpdatedAt': '上次調度時間', + 'pages.searchTable.exception': '請輸入異常原因!', + 'pages.searchTable.titleOption': '操作', + 'pages.searchTable.config': '配置', + 'pages.searchTable.subscribeAlert': '訂閱警報', + 'pages.searchTable.title': '查詢表格', + 'pages.searchTable.new': '新建', + 'pages.searchTable.edit': '編輯', + 'pages.searchTable.delete': '刪除', + 'pages.searchTable.export': '導出', + 'pages.searchTable.chosen': '已選擇', + 'pages.searchTable.item': '項', + 'pages.searchTable.totalServiceCalls': '服務調用次數總計', + 'pages.searchTable.tenThousand': '萬', + 'pages.searchTable.batchDeletion': '批量刪除', + 'pages.searchTable.batchApproval': '批量審批', +}; diff --git a/react-ui/src/locales/zh-TW/pwa.ts b/react-ui/src/locales/zh-TW/pwa.ts new file mode 100644 index 00000000..108a6e48 --- /dev/null +++ b/react-ui/src/locales/zh-TW/pwa.ts @@ -0,0 +1,6 @@ +export default { + 'app.pwa.offline': '當前處於離線狀態', + 'app.pwa.serviceworker.updated': '有新內容', + 'app.pwa.serviceworker.updated.hint': '請點擊“刷新”按鈕或者手動刷新頁面', + 'app.pwa.serviceworker.updated.ok': '刷新', +}; diff --git a/react-ui/src/locales/zh-TW/settingDrawer.ts b/react-ui/src/locales/zh-TW/settingDrawer.ts new file mode 100644 index 00000000..454da285 --- /dev/null +++ b/react-ui/src/locales/zh-TW/settingDrawer.ts @@ -0,0 +1,31 @@ +export default { + 'app.setting.pagestyle': '整體風格設置', + 'app.setting.pagestyle.dark': '暗色菜單風格', + 'app.setting.pagestyle.light': '亮色菜單風格', + 'app.setting.content-width': '內容區域寬度', + 'app.setting.content-width.fixed': '定寬', + 'app.setting.content-width.fluid': '流式', + 'app.setting.themecolor': '主題色', + 'app.setting.themecolor.dust': '薄暮', + 'app.setting.themecolor.volcano': '火山', + 'app.setting.themecolor.sunset': '日暮', + 'app.setting.themecolor.cyan': '明青', + 'app.setting.themecolor.green': '極光綠', + 'app.setting.themecolor.daybreak': '拂曉藍(默認)', + 'app.setting.themecolor.geekblue': '極客藍', + 'app.setting.themecolor.purple': '醬紫', + 'app.setting.navigationmode': '導航模式', + 'app.setting.sidemenu': '側邊菜單布局', + 'app.setting.topmenu': '頂部菜單布局', + 'app.setting.fixedheader': '固定 Header', + 'app.setting.fixedsidebar': '固定側邊菜單', + 'app.setting.fixedsidebar.hint': '側邊菜單布局時可配置', + 'app.setting.hideheader': '下滑時隱藏 Header', + 'app.setting.hideheader.hint': '固定 Header 時可配置', + 'app.setting.othersettings': '其他設置', + 'app.setting.weakmode': '色弱模式', + 'app.setting.copy': '拷貝設置', + 'app.setting.copyinfo': '拷貝成功,請到 config/defaultSettings.js 中替換默認配置', + 'app.setting.production.hint': + '配置欄只在開發環境用於預覽,生產環境不會展現,請拷貝後手動修改配置文件', +}; diff --git a/react-ui/src/locales/zh-TW/settings.ts b/react-ui/src/locales/zh-TW/settings.ts new file mode 100644 index 00000000..dd45151a --- /dev/null +++ b/react-ui/src/locales/zh-TW/settings.ts @@ -0,0 +1,55 @@ +export default { + 'app.settings.menuMap.basic': '基本設置', + 'app.settings.menuMap.security': '安全設置', + 'app.settings.menuMap.binding': '賬號綁定', + 'app.settings.menuMap.notification': '新消息通知', + 'app.settings.basic.avatar': '頭像', + 'app.settings.basic.change-avatar': '更換頭像', + 'app.settings.basic.email': '郵箱', + 'app.settings.basic.email-message': '請輸入您的郵箱!', + 'app.settings.basic.nickname': '昵稱', + 'app.settings.basic.nickname-message': '請輸入您的昵稱!', + 'app.settings.basic.profile': '個人簡介', + 'app.settings.basic.profile-message': '請輸入個人簡介!', + 'app.settings.basic.profile-placeholder': '個人簡介', + 'app.settings.basic.country': '國家/地區', + 'app.settings.basic.country-message': '請輸入您的國家或地區!', + 'app.settings.basic.geographic': '所在省市', + 'app.settings.basic.geographic-message': '請輸入您的所在省市!', + 'app.settings.basic.address': '街道地址', + 'app.settings.basic.address-message': '請輸入您的街道地址!', + 'app.settings.basic.phone': '聯系電話', + 'app.settings.basic.phone-message': '請輸入您的聯系電話!', + 'app.settings.basic.update': '更新基本信息', + 'app.settings.security.strong': '強', + 'app.settings.security.medium': '中', + 'app.settings.security.weak': '弱', + 'app.settings.security.password': '賬戶密碼', + 'app.settings.security.password-description': '當前密碼強度', + 'app.settings.security.phone': '密保手機', + 'app.settings.security.phone-description': '已綁定手機', + 'app.settings.security.question': '密保問題', + 'app.settings.security.question-description': '未設置密保問題,密保問題可有效保護賬戶安全', + 'app.settings.security.email': '備用郵箱', + 'app.settings.security.email-description': '已綁定郵箱', + 'app.settings.security.mfa': 'MFA 設備', + 'app.settings.security.mfa-description': '未綁定 MFA 設備,綁定後,可以進行二次確認', + 'app.settings.security.modify': '修改', + 'app.settings.security.set': '設置', + 'app.settings.security.bind': '綁定', + 'app.settings.binding.taobao': '綁定淘寶', + 'app.settings.binding.taobao-description': '當前未綁定淘寶賬號', + 'app.settings.binding.alipay': '綁定支付寶', + 'app.settings.binding.alipay-description': '當前未綁定支付寶賬號', + 'app.settings.binding.dingding': '綁定釘釘', + 'app.settings.binding.dingding-description': '當前未綁定釘釘賬號', + 'app.settings.binding.bind': '綁定', + 'app.settings.notification.password': '賬戶密碼', + 'app.settings.notification.password-description': '其他用戶的消息將以站內信的形式通知', + 'app.settings.notification.messages': '系統消息', + 'app.settings.notification.messages-description': '系統消息將以站內信的形式通知', + 'app.settings.notification.todo': '待辦任務', + 'app.settings.notification.todo-description': '待辦任務將以站內信的形式通知', + 'app.settings.open': '開', + 'app.settings.close': '關', +}; diff --git a/react-ui/src/manifest.json b/react-ui/src/manifest.json new file mode 100644 index 00000000..839bc5b5 --- /dev/null +++ b/react-ui/src/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Ant Design Pro", + "short_name": "Ant Design Pro", + "display": "standalone", + "start_url": "./?utm_source=homescreen", + "theme_color": "#002140", + "background_color": "#001529", + "icons": [ + { + "src": "icons/icon-192x192.png", + "sizes": "192x192" + }, + { + "src": "icons/icon-128x128.png", + "sizes": "128x128" + }, + { + "src": "icons/icon-512x512.png", + "sizes": "512x512" + } + ] +} diff --git a/react-ui/src/overrides.less b/react-ui/src/overrides.less new file mode 100644 index 00000000..bfdc938d --- /dev/null +++ b/react-ui/src/overrides.less @@ -0,0 +1,260 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-28 08:47:43 + * @Description: 覆盖 antd 样式 + */ + +// 设置 Table 可以滑动,带分页 +.vertical-scroll-table { + .ant-table-wrapper { + height: 100%; + .ant-spin-nested-loading { + height: 100%; + + .ant-spin-container { + height: 100%; + + .ant-table { + height: calc(100% - 74px); // 分页控件的高度 + + .ant-table-container { + height: 100%; + + .ant-table-body { + overflow-y: auto !important; + } + } + } + } + } + } +} + +// 设置 Table 可以滑动,没有分页 +.vertical-scroll-table-no-page { + .ant-table-wrapper { + height: 100%; + .ant-spin-nested-loading { + height: 100%; + + .ant-spin-container { + height: 100%; + + .ant-table { + height: 100%; + + .ant-table-container { + height: 100%; + + .ant-table-body { + overflow-y: auto !important; + } + } + } + } + } + } +} + +// Tabs 样式 +// 删除底部白色横线 +.ant-tabs { + .ant-tabs-nav::before { + border: none; + } + + // 删除下边的 margin-bottom + .ant-tabs-nav { + margin-bottom: 0; + } +} + +// 表格样式 +.ant-table-header { + border: 1px solid rgba(167, 178, 194, 0.17); + border-bottom: none; +} + +.ant-table-wrapper .ant-table-thead > tr > td { + background-color: #fff; +} + +.ant-table-row-selected { + .ant-table-cell { + color: @primary-color; + } +} + +.ant-pro-page-container { + overflow-y: auto; +} + +// Modal +.ant-modal { + .ant-modal-close { + top: 27px; + right: 27px; + width: 26px; + height: 26px; + color: @text-color-secondary; + border: 2px solid @text-color-secondary; + border-radius: 50%; + + &:hover, + &:active { + color: #272536; + background-color: transparent; + border: 2px solid #272536; + } + } + + .ant-form-item .ant-form-item-label > label { + font-size: @font-size; + } + + // 输入框高度为46px + .ant-input-affix-wrapper { + padding-top: 2px; + padding-bottom: 2px; + + .ant-input { + height: 40px; + } + } + + .ant-input.ant-input-disabled { + height: 46px; + } + + // 选择框高度为46px + .ant-select-single { + height: 46px; + } + + .ant-input-number { + .ant-input-number-input { + height: 44px; + } + } +} + +// Confirm Modal +.ant-modal-confirm { + .ant-modal-content { + padding: 40px 67px; + background-image: url(@/assets/img/modal-back.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100%; + border-radius: 20px; + } + .ant-modal-confirm-body { + .anticon { + display: none; + } + } + + .ant-modal-confirm-paragraph { + max-width: 100%; + margin-top: 27px; + text-align: center; + } + + .ant-modal-confirm-btns { + margin-top: 40px; + text-align: center; + + .ant-btn { + height: 40px; + padding: 0 30px; + font-size: @font-size-content; + border-radius: 6px; + } + .ant-btn-default { + border-color: transparent; + } + .ant-btn + .ant-btn { + margin-left: 20px; + } + } +} + +// 表单类型为large时,font-size为15px +.ant-form-large { + .ant-form-item-label { + label { + font-size: @font-size; + } + } +} + +// 取消 hover 颜色变化 +.ant-menu .ant-menu-title-content { + transition: color 0s; + a { + transition: color 0s; + } +} + +.ant-pro-sider-collapsed-button { + inset-block-start: 65px !important; +} + +.ant-pro-layout .ant-pro-sider-logo > a > h1 { + margin-inline-start: 12px; +} + +.ant-pro-layout .ant-pro-sider-logo-collapsed { + padding: 16px 12px; +} + +.ant-pro-base-menu-inline .ant-pro-base-menu-inline-menu-item { + transition: padding 0.1s !important; +} + +// PageContainer 里的 ProTable 只滑动内容区域 +.system-menu.ant-pro-page-container { + height: 100%; + overflow: hidden; + .ant-pro-grid-content { + height: 100%; + .ant-pro-grid-content-children { + height: 100%; + .ant-pro-page-container-children-container { + height: 100%; + padding: 0; + .ant-pro-table { + display: flex; + flex-direction: column; + height: 100%; + .ant-pro-card.ant-pro-table-search { + flex: none; + height: auto; + } + .ant-pro-card { + flex: 1; + min-height: 0; + .ant-pro-card-body { + height: 100%; + .ant-table-wrapper { + height: calc(100% - 64px - 64px); + .ant-spin-nested-loading { + height: 100%; + .ant-spin-container { + height: 100%; + .ant-table-fixed-header { + height: 100%; + .ant-table-container { + height: 100%; + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/react-ui/src/pages/404.tsx b/react-ui/src/pages/404.tsx new file mode 100644 index 00000000..4166c245 --- /dev/null +++ b/react-ui/src/pages/404.tsx @@ -0,0 +1,21 @@ +import KFEmpty, { EmptyType } from '@/components/KFEmpty'; +import { HomeUrl } from '@/utils/constant'; +import { useNavigate } from '@umijs/max'; + +const NoFoundPage = () => { + const navigate = useNavigate(); + + return ( + navigate(HomeUrl)} + > + ); +}; + +export default NoFoundPage; diff --git a/react-ui/src/pages/ActiveLearn/Create/index.less b/react-ui/src/pages/ActiveLearn/Create/index.less new file mode 100644 index 00000000..145be0d1 --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/Create/index.less @@ -0,0 +1,55 @@ +.create-hyperparameter { + height: 100%; + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 30px 30px 10px; + overflow: auto; + color: @text-color; + font-size: @font-size-content; + background-color: white; + border-radius: 10px; + + &__type { + color: @text-color; + font-size: @font-size-input-lg; + } + + :global { + .ant-input-number { + width: 100%; + } + + .ant-form-item { + margin-bottom: 20px; + } + + .image-url { + margin-top: -15px; + .ant-form-item-label > label::after { + content: ''; + } + } + + .ant-btn-variant-text:disabled { + color: @text-disabled-color; + } + + .ant-btn-variant-text { + color: #565658; + } + + .ant-btn.ant-btn-icon-only .anticon { + font-size: 20px; + } + + .anticon-question-circle { + margin-top: -12px; + margin-left: 1px !important; + color: @text-color-tertiary !important; + font-size: 12px !important; + } + } + } +} diff --git a/react-ui/src/pages/ActiveLearn/Create/index.tsx b/react-ui/src/pages/ActiveLearn/Create/index.tsx new file mode 100644 index 00000000..5b8fe0f8 --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/Create/index.tsx @@ -0,0 +1,141 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 创建实验 + */ +import PageTitle from '@/components/PageTitle'; +import { AutoMLTaskType } from '@/enums'; +import { + addActiveLearnReq, + getActiveLearnInfoReq, + updateActiveLearnReq, +} from '@/services/activeLearn'; +import { safeInvoke } from '@/utils/functional'; +import { to } from '@/utils/promise'; +import { useLocation, useNavigate, useParams } from '@umijs/max'; +import { App, Button, Form } from 'antd'; +import { useEffect } from 'react'; +import BasicConfig from '../components/CreateForm/BasicConfig'; +import ExecuteConfig from '../components/CreateForm/ExecuteConfig'; +import { ActiveLearnData, FormData } from '../types'; +import styles from './index.less'; + +function CreateActiveLearn() { + const navigate = useNavigate(); + const [form] = Form.useForm(); + const { message } = App.useApp(); + const params = useParams(); + const id = safeInvoke(Number)(params.id); + const { pathname } = useLocation(); + const isCopy = pathname.includes('copy'); + + useEffect(() => { + // 获取服务详情 + const getActiveLearnInfo = async (id: number) => { + const [res] = await to(getActiveLearnInfoReq({ id })); + if (res && res.data) { + const info: ActiveLearnData = res.data; + const { name: name_str, ...rest } = info; + const name = isCopy ? `${name_str}-copy` : name_str; + const formData = { + ...rest, + name, + }; + + form.setFieldsValue(formData); + } + }; + // 编辑,复制 + if (id && !Number.isNaN(id)) { + getActiveLearnInfo(id); + } + }, [id, isCopy, form]); + + // 创建、更新、复制实验 + const createExperiment = async (formData: FormData) => { + // 根据后台要求,修改表单数据 + const object = { + ...formData, + }; + + const params = + id && !isCopy + ? { + id: id, + ...object, + } + : object; + + const request = id && !isCopy ? updateActiveLearnReq : addActiveLearnReq; + const [res] = await to(request(params)); + if (res) { + message.success('操作成功'); + navigate(-1); + } + }; + + // 提交 + const handleSubmit = (values: FormData) => { + createExperiment(values); + }; + + // 取消 + const cancel = () => { + navigate(-1); + }; + + let buttonText = '新建'; + let title = '新建实验'; + if (id) { + if (isCopy) { + title = '复制实验'; + buttonText = '确定'; + } else { + title = '编辑实验'; + buttonText = '更新'; + } + } + + return ( +
+ +
+
+
+ + + + + + + + +
+
+
+ ); +} + +export default CreateActiveLearn; diff --git a/react-ui/src/pages/ActiveLearn/Info/index.less b/react-ui/src/pages/ActiveLearn/Info/index.less new file mode 100644 index 00000000..e27756ef --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/Info/index.less @@ -0,0 +1,40 @@ +.auto-ml-info { + position: relative; + height: 100%; + &__tabs { + height: 50px; + padding-left: 25px; + background-image: url(@/assets/img/page-title-bg.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + } + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + } + + &__tips { + position: absolute; + top: 11px; + left: 256px; + padding: 3px 12px; + color: #565658; + font-size: @font-size-content; + background: .addAlpha(@primary-color, 0.09) []; + border-radius: 4px; + + &::before { + position: absolute; + top: 10px; + left: -6px; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-right: 6px solid .addAlpha(@primary-color, 0.09) []; + border-bottom: 4px solid transparent; + content: ''; + } + } +} diff --git a/react-ui/src/pages/ActiveLearn/Info/index.tsx b/react-ui/src/pages/ActiveLearn/Info/index.tsx new file mode 100644 index 00000000..0ba305bb --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/Info/index.tsx @@ -0,0 +1,44 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 主动学习实验详情 + */ +import PageTitle from '@/components/PageTitle'; +import { getActiveLearnInfoReq } from '@/services/activeLearn'; +import { safeInvoke } from '@/utils/functional'; +import { to } from '@/utils/promise'; +import { useParams } from '@umijs/max'; +import { useEffect, useState } from 'react'; +import ActiveLearnBasic from '../components/ActiveLearnBasic'; +import { ActiveLearnData } from '../types'; +import styles from './index.less'; + +function ActiveLearnInfo() { + const params = useParams(); + const id = safeInvoke(Number)(params.id); + const [info, setInfo] = useState(undefined); + + useEffect(() => { + // 获取详情 + const getActiveLearnInfo = async () => { + const [res] = await to(getActiveLearnInfoReq({ id: id })); + if (res && res.data) { + setInfo(res.data); + } + }; + if (id) { + getActiveLearnInfo(); + } + }, [id]); + + return ( +
+ +
+ +
+
+ ); +} + +export default ActiveLearnInfo; diff --git a/react-ui/src/pages/ActiveLearn/Instance/index.less b/react-ui/src/pages/ActiveLearn/Instance/index.less new file mode 100644 index 00000000..168e8de5 --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/Instance/index.less @@ -0,0 +1,42 @@ +.active-learn-instance { + height: 100%; + + &__tabs { + height: 100%; + :global { + .ant-tabs-nav-list { + width: 100%; + height: 50px; + padding-left: 15px; + background-image: url(@/assets/img/page-title-bg.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + } + + .ant-tabs-content-holder { + height: calc(100% - 50px); + .ant-tabs-content { + height: 100%; + .ant-tabs-tabpane { + height: 100%; + } + } + } + } + } + + &__basic { + height: calc(100% - 10px); + margin-top: 10px; + } + + &__log { + height: calc(100% - 10px); + margin-top: 10px; + padding: 20px calc(@content-padding - 8px); + overflow-y: visible; + background-color: white; + border-radius: 10px; + } +} diff --git a/react-ui/src/pages/ActiveLearn/Instance/index.tsx b/react-ui/src/pages/ActiveLearn/Instance/index.tsx new file mode 100644 index 00000000..0a71b24b --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/Instance/index.tsx @@ -0,0 +1,211 @@ +import KFIcon from '@/components/KFIcon'; +import { AutoMLTaskType, ExperimentStatus } from '@/enums'; +import { getActiveLearnInsReq } from '@/services/activeLearn'; +import { NodeStatus } from '@/types'; +import { parseJsonText } from '@/utils'; +import { safeInvoke } from '@/utils/functional'; +import { to } from '@/utils/promise'; +import { useParams } from '@umijs/max'; +import { Tabs } from 'antd'; +import { useEffect, useRef, useState } from 'react'; +import ActiveLearnBasic from '../components/ActiveLearnBasic'; +import ExperimentHistory from '../components/ExperimentHistory'; +import ExperimentLog from '../components/ExperimentLog'; +import ExperimentResult from '../components/ExperimentResult'; +import ExperimentVisualResult from '../components/ExperimentVisualResult'; +import { ActiveLearnData, ActiveLearnInstanceData } from '../types'; +import styles from './index.less'; + +enum TabKeys { + Params = 'params', + Log = 'log', + Result = 'result', + History = 'history', + Visual = 'Visual', +} + +const NodePrefix = 'workflow'; + +function ActiveLearnInstance() { + const [experimentInfo, setExperimentInfo] = useState(undefined); + const [instanceInfo, setInstanceInfo] = useState(undefined); + // 主动学习运行有3个节点,运行状态取工作流状态,而不是 active-learn 节点状态 + const [workflowStatus, setWorkflowStatus] = useState(undefined); + const [nodes, setNodes] = useState | undefined>(undefined); + const params = useParams(); + const instanceId = safeInvoke(Number)(params.id); + const evtSourceRef = useRef(null); + + useEffect(() => { + if (instanceId) { + getExperimentInsInfo(false); + } + return () => { + closeSSE(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instanceId]); + + // 获取实验实例详情 + const getExperimentInsInfo = async (isStatusDetermined: boolean) => { + const [res] = await to(getActiveLearnInsReq(instanceId)); + if (res && res.data) { + const info = res.data as ActiveLearnInstanceData; + const { param, node_status, argo_ins_name, argo_ins_ns, status } = info; + // 解析配置参数 + const paramJson = parseJsonText(param); + if (paramJson) { + setExperimentInfo({ + ...paramJson.data, + }); + } + + setInstanceInfo(info); + + // 这个接口返回的状态有延时,SSE 返回的状态是最新的 + // SSE 调用时,不需要解析 node_status,也不要重新建立 SSE + if (isStatusDetermined) { + return; + } + + // 设置总 workflow 状态 + const nodeStatusJson = parseJsonText(node_status); + if (nodeStatusJson) { + setNodes(nodeStatusJson); + Object.keys(nodeStatusJson).some((key) => { + if (key.startsWith(NodePrefix)) { + const workflowStatus = nodeStatusJson[key]; + setWorkflowStatus(workflowStatus); + return true; + } + return false; + }); + } + + // 运行中或者等待中,开启 SSE + if (status === ExperimentStatus.Pending || status === ExperimentStatus.Running) { + setupSSE(argo_ins_name, argo_ins_ns); + } + } + }; + + const setupSSE = (name: string, namespace: string) => { + const { origin } = location; + const params = encodeURIComponent(`metadata.namespace=${namespace},metadata.name=${name}`); + const evtSource = new EventSource( + `${origin}/api/v1/realtimeStatus?listOptions.fieldSelector=${params}`, + { withCredentials: false }, + ); + evtSource.onmessage = (event) => { + const data = event?.data; + if (!data) { + return; + } + const dataJson = parseJsonText(data); + if (dataJson) { + const nodes = dataJson?.result?.object?.status?.nodes; + if (nodes) { + // 设置节点 + setNodes(nodes); + + // 设置总 workflow 状态 + const workflowStatus = Object.values(nodes).find((node: any) => + node.displayName.startsWith(NodePrefix), + ) as NodeStatus; + if (workflowStatus) { + setWorkflowStatus(workflowStatus); + + // 实验结束,关闭 SSE,获取实验实例结果 + if ( + workflowStatus.phase !== ExperimentStatus.Pending && + workflowStatus.phase !== ExperimentStatus.Running + ) { + closeSSE(); + getExperimentInsInfo(true); + } + } + } + } + }; + evtSource.onerror = (error) => { + console.error('SSE error: ', error); + }; + + evtSourceRef.current = evtSource; + }; + + const closeSSE = () => { + if (evtSourceRef.current) { + evtSourceRef.current.close(); + evtSourceRef.current = null; + } + }; + + const basicTabItems = [ + { + key: TabKeys.Params, + label: '基本信息', + icon: , + children: ( + + ), + }, + { + key: TabKeys.Log, + label: '日志', + icon: , + children: ( +
+ {instanceInfo && nodes && } +
+ ), + }, + ]; + + const resultTabItems = [ + { + key: TabKeys.Result, + label: '实验结果', + icon: , + children: ( + + ), + }, + { + key: TabKeys.History, + label: '运行列表', + icon: , + children: ( + + ), + }, + { + key: TabKeys.Visual, + label: '可视化结果', + icon: , + children: , + }, + ]; + + const tabItems = + instanceInfo?.status === ExperimentStatus.Succeeded + ? [...basicTabItems, ...resultTabItems] + : basicTabItems; + + return ( +
+ +
+ ); +} + +export default ActiveLearnInstance; diff --git a/react-ui/src/pages/ActiveLearn/List/index.tsx b/react-ui/src/pages/ActiveLearn/List/index.tsx new file mode 100644 index 00000000..01e316bd --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/List/index.tsx @@ -0,0 +1,13 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 超参数自动寻优 + */ + +import ExperimentList, { ExperimentListType } from '@/pages/AutoML/components/ExperimentList'; + +function ActiveLearn() { + return ; +} + +export default ActiveLearn; diff --git a/react-ui/src/pages/ActiveLearn/components/ActiveLearnBasic/index.less b/react-ui/src/pages/ActiveLearn/components/ActiveLearnBasic/index.less new file mode 100644 index 00000000..a357f2ee --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/components/ActiveLearnBasic/index.less @@ -0,0 +1,13 @@ +.active-learn-basic { + height: 100%; + padding: 20px @content-padding; + overflow-y: auto; + background-color: white; + border-radius: 10px; + + :global { + .kf-basic-info__item__value__text { + white-space: pre; + } + } +} diff --git a/react-ui/src/pages/ActiveLearn/components/ActiveLearnBasic/index.tsx b/react-ui/src/pages/ActiveLearn/components/ActiveLearnBasic/index.tsx new file mode 100644 index 00000000..82ad98d9 --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/components/ActiveLearnBasic/index.tsx @@ -0,0 +1,236 @@ +import ConfigInfo, { type BasicInfoData } from '@/components/ConfigInfo'; +import { AutoMLTaskType, autoMLTaskTypeOptions, ExperimentStatus } from '@/enums'; +import { useSystemResource } from '@/hooks/useComputingResource'; +import { + classifierAlgorithms, + FrameworkType, + frameworkTypeOptions, + queryStrategies, + regressorAlgorithms, +} from '@/pages/ActiveLearn/components/CreateForm/utils'; +import { ActiveLearnData } from '@/pages/ActiveLearn/types'; +import ExperimentRunBasic from '@/pages/AutoML/components/ExperimentRunBasic'; +import { type NodeStatus } from '@/types'; +import { + formatBoolean, + formatCodeConfig, + formatDataset, + formatDate, + formatEnum, + formatMirror, + formatModel, +} from '@/utils/format'; +import classNames from 'classnames'; +import { useMemo } from 'react'; +import styles from './index.less'; + +type BasicInfoProps = { + info?: ActiveLearnData; + className?: string; + isInstance?: boolean; + workflowStatus?: NodeStatus; + instanceStatus?: ExperimentStatus; +}; + +function BasicInfo({ + info, + className, + workflowStatus, + instanceStatus, + isInstance = false, +}: BasicInfoProps) { + const getResourceDescription = useSystemResource(); + const basicDatas: BasicInfoData[] = useMemo(() => { + if (!info) { + return []; + } + + return [ + { + label: '实验名称', + value: info.name, + }, + { + label: '实验描述', + value: info.description, + }, + { + label: '创建人', + value: info.create_by, + }, + { + label: '创建时间', + value: info.create_time, + format: formatDate, + }, + { + label: '更新时间', + value: info.update_time, + format: formatDate, + }, + ]; + }, [info]); + + const configDatas: BasicInfoData[] = useMemo(() => { + if (!info) { + return []; + } + + const modelInfo = [ + { + label: '预训练模型', + value: info.model, + format: formatModel, + }, + { + label: '模型文件路径', + value: info.model_py, + }, + { + label: '模型类名称', + value: info.model_class_name, + }, + { + label: 'epochs', + value: info.epochs, + }, + { + label: 'batch_size', + value: info.batch_size, + }, + ]; + + const lossInfo = [ + { + label: '学习率', + value: info.lr, + }, + { + label: 'loss文件路径', + value: info.loss_py, + }, + { + label: 'loss类名', + value: info.loss_class_name, + }, + ]; + + const algorithmInfo = [ + { + label: info.task_type === AutoMLTaskType.Regression ? '回归算法' : '分类算法', + value: + info.task_type === AutoMLTaskType.Regression ? info.regressor_alg : info.classifier_alg, + format: formatEnum( + info.task_type === AutoMLTaskType.Regression ? regressorAlgorithms : classifierAlgorithms, + ), + }, + ]; + + const diffInfo = + info.framework_type === FrameworkType.Pytorch + ? [...modelInfo, ...lossInfo] + : info.framework_type === FrameworkType.Keras + ? modelInfo + : algorithmInfo; + + return [ + { + label: '任务类型', + value: info.task_type, + format: formatEnum(autoMLTaskTypeOptions), + }, + { + label: '代码配置', + value: info.code_config, + format: formatCodeConfig, + }, + { + label: '数据集', + value: info.dataset, + format: formatDataset, + }, + { + label: '数据集处理文件路径', + value: info.dataset_py, + }, + { + label: '数据集处理类名', + value: info.dataset_class_name, + }, + { + label: '镜像', + value: info.image, + format: formatMirror, + }, + { + label: '资源规格', + value: info.computing_resource_id, + format: getResourceDescription, + }, + { + label: '框架类型', + value: info.framework_type, + format: formatEnum(frameworkTypeOptions), + }, + ...diffInfo, + { + label: '是否打乱', + value: info.shuffle, + format: formatBoolean, + }, + { + label: '数据量', + value: info.data_size, + }, + { + label: '训练集数据量', + value: info.train_size, + }, + { + label: '初始训练数据量', + value: info.initial_num, + }, + { + label: '查询次数', + value: info.queries_num, + }, + { + label: '每次查询数据量', + value: info.instances_num, + }, + { + label: '查询策略', + value: info.query_strategy, + format: formatEnum(queryStrategies), + }, + { + label: '检查点轮数', + value: info.checkpoint_num, + }, + ]; + }, [info, getResourceDescription]); + + return ( +
+ {isInstance && workflowStatus && ( + + )} + {!isInstance && ( + + )} + +
+ ); +} + +export default BasicInfo; diff --git a/react-ui/src/pages/ActiveLearn/components/CreateForm/BasicConfig.tsx b/react-ui/src/pages/ActiveLearn/components/CreateForm/BasicConfig.tsx new file mode 100644 index 00000000..9d06fd10 --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/components/CreateForm/BasicConfig.tsx @@ -0,0 +1,58 @@ +import SubAreaTitle from '@/components/SubAreaTitle'; +import { Col, Form, Input, Row } from 'antd'; + +function BasicConfig() { + return ( + <> + + + + + + + + + + + + + + + + + ); +} + +export default BasicConfig; diff --git a/react-ui/src/pages/ActiveLearn/components/CreateForm/ExecuteConfig.tsx b/react-ui/src/pages/ActiveLearn/components/CreateForm/ExecuteConfig.tsx new file mode 100644 index 00000000..c5d1f476 --- /dev/null +++ b/react-ui/src/pages/ActiveLearn/components/CreateForm/ExecuteConfig.tsx @@ -0,0 +1,527 @@ +import CodeSelect from '@/components/CodeSelect'; +import ParameterSelect from '@/components/ParameterSelect'; +import ResourceSelect, { + requiredValidator, + ResourceSelectorType, +} from '@/components/ResourceSelect'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { AutoMLTaskType, autoMLTaskTypeOptions } from '@/enums'; +import { Col, Form, Input, InputNumber, Radio, Row, Select, Switch } from 'antd'; +import { + classifierAlgorithms, + FrameworkType, + frameworkTypeOptions, + queryStrategies, + regressorAlgorithms, +} from './utils'; + +function ExecuteConfig() { + const form = Form.useFormInstance(); + const task_type = Form.useWatch('task_type', form); + const queryStrategiesOptions = + task_type === AutoMLTaskType.Classification + ? queryStrategies.slice(0, 2) + : queryStrategies.slice(2); + return ( + <> + + + + + + form.resetFields(['metrics'])} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {frameworkType === FrameworkType.Pytorch ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + ) : null} + + ); + } else if (frameworkType === FrameworkType.Sklearn) { + if (taskType === AutoMLTaskType.Classification) { + return ( + <> + + + + + + + + + ); + } + } else { + return null; + } + }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default BasicConfig; diff --git a/react-ui/src/pages/AutoML/components/CreateForm/DatasetConfig.tsx b/react-ui/src/pages/AutoML/components/CreateForm/DatasetConfig.tsx new file mode 100644 index 00000000..ae70e806 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/CreateForm/DatasetConfig.tsx @@ -0,0 +1,58 @@ +import ResourceSelect, { + ResourceSelectorType, + requiredValidator, +} from '@/components/ResourceSelect'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { Col, Form, Input, Row } from 'antd'; + +function DatasetConfig() { + return ( + <> + + + + + + + + + + + + + + + + + ); +} + +export default DatasetConfig; diff --git a/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfig.tsx b/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfig.tsx new file mode 100644 index 00000000..b68fdc9a --- /dev/null +++ b/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfig.tsx @@ -0,0 +1,404 @@ +import SubAreaTitle from '@/components/SubAreaTitle'; +import { + AutoMLEnsembleClass, + AutoMLResamplingStrategy, + AutoMLTaskType, + autoMLEnsembleClassOptions, + autoMLResamplingStrategyOptions, + autoMLTaskTypeOptions, +} from '@/enums'; +import { Col, Form, InputNumber, Radio, Row, Select, Switch } from 'antd'; +import { classificationAlgorithms, featureAlgorithms, regressorAlgorithms } from './utils'; + +// 分类指标 +export const classificationMetrics = [ + 'accuracy', + 'balanced_accuracy', + 'roc_auc', + 'average_precision', + 'log_loss', + 'precision_macro', + 'precision_micro', + 'precision_samples', + 'precision_weighted', + 'recall_macro', + 'recall_micro', + 'recall_samples', + 'recall_weighted', + 'f1_macro', + 'f1_micro', + 'f1_samples', + 'f1_weighted', +].map((name) => ({ label: name, value: name })); + +// 回归指标 +export const regressionMetrics = [ + 'mean_absolute_error', + 'mean_squared_error', + 'root_mean_squared_error', + 'mean_squared_log_error', + 'median_absolute_error', + 'r2', +].map((name) => ({ label: name, value: name })); + +function ExecuteConfig() { + const form = Form.useFormInstance(); + const task_type = Form.useWatch('task_type', form); + const include_classifier = Form.useWatch('include_classifier', form); + const exclude_classifier = Form.useWatch('exclude_classifier', form); + const include_regressor = Form.useWatch('include_regressor', form); + const exclude_regressor = Form.useWatch('exclude_regressor', form); + const include_feature_preprocessor = Form.useWatch('include_feature_preprocessor', form); + const exclude_feature_preprocessor = Form.useWatch('exclude_feature_preprocessor', form); + + return ( + <> + + + + + form.resetFields(['metrics'])} + > + + + + + + + + 0} + mode="multiple" + showSearch + /> + + + + + + {({ getFieldValue }) => { + return getFieldValue('task_type') === AutoMLTaskType.Classification ? ( + <> + + + + 0} + showSearch + /> + + + + + ) : ( + <> + + + + 0} + showSearch + /> + + + + + ); + }} + + + + + + + + + + + + {({ getFieldValue }) => { + return getFieldValue('ensemble_class') === AutoMLEnsembleClass.Default ? ( + <> + + + + + + + + + + + + + + + + + ) : null; + }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {({ getFieldValue }) => { + return getFieldValue('resampling_strategy') === AutoMLResamplingStrategy.CrossValid ? ( + + + + + + + + ) : null; + }} + + + + + + + + + + + + + + + + + + + ); +} + +export default ExecuteConfig; diff --git a/react-ui/src/pages/AutoML/components/CreateForm/TextExecuteConfig.tsx b/react-ui/src/pages/AutoML/components/CreateForm/TextExecuteConfig.tsx new file mode 100644 index 00000000..8910c473 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/CreateForm/TextExecuteConfig.tsx @@ -0,0 +1,137 @@ +import ParameterSelect from '@/components/ParameterSelect'; +import ResourceSelect, { + ResourceSelectorType, + requiredValidator, +} from '@/components/ResourceSelect'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { Col, Form, InputNumber, Row, Select } from 'antd'; + +// 模型 +const modelTypeOptions = [ + 'TextCNN', + 'TextRNN', + 'FasetText', + 'TextRCNN', + 'TextRNN_Att', + 'DPCNN', + 'Transformer', +].map((name) => ({ label: name, value: name })); + +function TextExecuteConfig() { + return ( + <> + + + + + + + + + + + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }, index) => ( + + + + + + + + + + + + + + + + + {({ getFieldValue }) => { + const is_validate = getFieldValue('is_validate'); + if (is_validate) { + return ( + <> + + + + + + + + + + + + + + + + + ); + } + }} + + + {/* + {({ getFieldValue }) => { + const is_test = getFieldValue('is_test'); + if (is_test) { + return ( + <> + + + + + + + + + + + + + + + + + ); + } + }} + */} + + ); +} + +export default VideoExecuteConfig; diff --git a/react-ui/src/pages/AutoML/components/CreateForm/index.less b/react-ui/src/pages/AutoML/components/CreateForm/index.less new file mode 100644 index 00000000..00caa827 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/CreateForm/index.less @@ -0,0 +1,29 @@ +.metrics-weight { + position: relative; + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + &__operation { + position: absolute; + left: calc(100% + 10px); + width: 76px; + height: 46px; + margin-left: 6px; + } +} + +.add-weight { + margin-bottom: 0 !important; + + // 增加样式权重 + & &__button { + border-color: .addAlpha(@primary-color, 0.5) []; + box-shadow: none !important; + &:hover { + border-style: solid !important; + } + } +} diff --git a/react-ui/src/pages/AutoML/components/CreateForm/utils.ts b/react-ui/src/pages/AutoML/components/CreateForm/utils.ts new file mode 100644 index 00000000..fa556e2a --- /dev/null +++ b/react-ui/src/pages/AutoML/components/CreateForm/utils.ts @@ -0,0 +1,85 @@ +// 分类算法 +export const classificationAlgorithms = [ + { label: 'adaboost (自适应提升算法)', value: 'adaboost' }, + { label: 'bernoulli_nb (伯努利朴素贝叶斯)', value: 'bernoulli_nb' }, + { label: 'decision_tree (决策树)', value: 'decision_tree' }, + { label: 'extra_trees (极端随机树)', value: 'extra_trees' }, + { label: 'gaussian_nb (高斯朴素贝叶斯)', value: 'gaussian_nb' }, + { label: 'gradient_boosting (梯度提升)', value: 'gradient_boosting' }, + { label: 'k_nearest_neighbors (k近邻)', value: 'k_nearest_neighbors' }, + { label: 'lda (线性判别分析)', value: 'lda' }, + { label: 'liblinear_svc (liblinear支持向量分类)', value: 'liblinear_svc' }, + { label: 'libsvm_svc (libsvm支持向量分类)', value: 'libsvm_svc' }, + { label: 'mlp (多层感知器)', value: 'mlp' }, + { label: 'multinomial_nb (多项式朴素贝叶斯)', value: 'multinomial_nb' }, + { label: 'passive_aggressive (被动攻击算法)', value: 'passive_aggressive' }, + { label: 'qda (二次判别式分析)', value: 'qda' }, + { label: 'random_forest (随机森林)', value: 'random_forest' }, + { label: 'sgd (随机梯度下降)', value: 'sgd' }, + { label: 'tablenet (表格网络)', value: 'tablenet' }, + { label: 'LightGBMClassification (轻量梯度提升机分类)', value: 'LightGBMClassification' }, + { label: 'XGBoostClassification (极端梯度提升机分类)', value: 'XGBoostClassification' }, + { label: 'StackingClassification (堆叠泛化)', value: 'StackingClassification' }, +]; + +// 回归算法 +export const regressorAlgorithms = [ + { label: 'adaboost (自适应提升算法)', value: 'adaboost' }, + { label: 'ard_regression (自动相关性确定回归)', value: 'ard_regression' }, + { label: 'decision_tree (决策树)', value: 'decision_tree' }, + { label: 'extra_trees (极端随机树)', value: 'extra_trees' }, + { label: 'gaussian_process (高斯过程回归)', value: 'gaussian_process' }, + { label: 'gradient_boosting (梯度提升)', value: 'gradient_boosting' }, + { label: 'k_nearest_neighbors (梯度提升)', value: 'k_nearest_neighbors' }, + { label: 'liblinear_svr (liblinear支持向量回归)', value: 'liblinear_svr' }, + { label: 'libsvm_svr (libsvm支持向量回归)', value: 'libsvm_svr' }, + { label: 'mlp (多层感知器)', value: 'mlp' }, + { label: 'random_forest (随机森林)', value: 'random_forest' }, + { label: 'sgd (随机梯度下降)', value: 'sgd' }, + { label: 'LightGBMRegression (轻量梯度提升机回归)', value: 'LightGBMRegression' }, + { label: 'XGBoostRegression (极端梯度提升机回归)', value: 'XGBoostRegression' }, +]; + +// 特征预处理算法 +export const featureAlgorithms = [ + { label: 'densifier (特征变换-数据增稠)', value: 'densifier' }, + { + label: 'extra_trees_preproc_for_classification (特征选择-分类任务极端随机树)', + value: 'extra_trees_preproc_for_classification', + }, + { + label: 'extra_trees_preproc_for_regression (特征选择-回归任务极端随机树)', + value: 'extra_trees_preproc_for_regression', + }, + { label: 'fast_ica (特征选择-快速独立成分分析)', value: 'fast_ica' }, + { label: 'feature_agglomeration (特征变换-特征聚合)', value: 'feature_agglomeration' }, + { label: 'kernel_pca (特征选择-核主成分分析)', value: 'kernel_pca' }, + { label: 'kitchen_sinks (特征变换-随机特征映射)', value: 'kitchen_sinks' }, + { + label: 'liblinear_svc_preprocessor (特征选择-线性svc预处理器)', + value: 'liblinear_svc_preprocessor', + }, + { label: 'miss_value_impute (缺失值填充)', value: 'miss_value_impute' }, + { label: 'no_preprocessing (无预处理)', value: 'no_preprocessing' }, + { label: 'nystroem_sampler (特征变换-尼斯特罗姆采样器)', value: 'nystroem_sampler' }, + { label: 'pca (特征选择-主成分分析)', value: 'pca' }, + { label: 'polynomial (特征变换-多项式特征扩展)', value: 'polynomial' }, + { label: 'random_trees_embedding (特征变换-随机森林特征嵌入)', value: 'random_trees_embedding' }, + { + label: 'select_percentile_classification 特征选择-基于百分位的分类特征选择)', + value: 'select_percentile_classification', + }, + { + label: 'select_percentile_regression (特征选择-基于百分位的回归特征选择)', + value: 'select_percentile_regression', + }, + { + label: 'select_rates_classification (特征选择-基于比率的分类特征选择)', + value: 'select_rates_classification', + }, + { + label: 'select_rates_regression (特征选择-基于比率的回归特征选择)', + value: 'select_rates_regression', + }, + { label: 'truncatedSVD (特征变换-截断奇异值分解)', value: 'truncatedSVD' }, +]; diff --git a/react-ui/src/pages/AutoML/components/ExperimentHistory/index.less b/react-ui/src/pages/AutoML/components/ExperimentHistory/index.less new file mode 100644 index 00000000..beac2a8a --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentHistory/index.less @@ -0,0 +1,14 @@ +.experiment-history { + height: calc(100% - 10px); + margin-top: 10px; + &__content { + height: 100%; + padding: 20px @content-padding; + background-color: white; + border-radius: 10px; + + &__table { + height: 100%; + } + } +} diff --git a/react-ui/src/pages/AutoML/components/ExperimentHistory/index.tsx b/react-ui/src/pages/AutoML/components/ExperimentHistory/index.tsx new file mode 100644 index 00000000..dbe06179 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentHistory/index.tsx @@ -0,0 +1,132 @@ +import { getFileReq } from '@/services/file'; +import { to } from '@/utils/promise'; +import tableCellRender from '@/utils/table'; +import { Table, type TableProps } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; +import TrialStatusCell from '../TrialStatusCell'; +import styles from './index.less'; + +type ExperimentHistoryProps = { + calcMetrics?: string; // 计算指标 + fileUrl?: string; // 文件url + isClassification: boolean; // 是否是分类 +}; + +type TableData = { + id?: string; + accuracy?: number; + duration?: number; + train_loss?: number; + status?: string; + feature?: string; + althorithm?: string; +}; + +function ExperimentHistory({ calcMetrics, fileUrl, isClassification }: ExperimentHistoryProps) { + const [tableData, setTableData] = useState([]); + useEffect(() => { + // 获取实验运行历史记录 + const getHistoryFile = async () => { + const [res] = await to(getFileReq(fileUrl)); + if (res) { + const data: any[] = res.data; + const list: TableData[] = data.map((item) => { + return { + id: item[0]?.[0], + accuracy: calcMetrics ? item[1]?.[5]?.[calcMetrics] : undefined, + duration: item[1]?.[5]?.duration, + train_loss: item[1]?.[5]?.train_loss, + status: item[1]?.[2]?.['__enum__']?.split('.')?.[1], + }; + }); + list.forEach((item) => { + if (!item.id) return; + const config = (res as any).configs?.[item.id]; + item.feature = config?.['feature_preprocessor:__choice__']; + item.althorithm = isClassification + ? config?.['classifier:__choice__'] + : config?.['regressor:__choice__']; + }); + setTableData(list); + } + }; + + if (fileUrl) { + getHistoryFile(); + } + }, [fileUrl, isClassification, calcMetrics]); + + const columns: TableProps['columns'] = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 80, + render: tableCellRender(false), + }, + { + title: '耗时', + dataIndex: 'duration', + key: 'duration', + render: tableCellRender(true), + }, + { + title: '训练损失', + dataIndex: 'train_loss', + key: 'train_loss', + render: tableCellRender(true), + }, + { + title: '特征处理', + dataIndex: 'feature', + key: 'feature', + render: tableCellRender(true), + }, + { + title: '算法', + dataIndex: 'althorithm', + key: 'althorithm', + render: tableCellRender(true), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 150, + render: TrialStatusCell, + }, + ]; + + if (calcMetrics) { + columns.splice(0, 0, { + title: `指标:${calcMetrics}`, + dataIndex: 'accuracy', + key: 'accuracy', + render: tableCellRender(true), + }); + } + + return ( +
+
+
+ + + + + ); +} + +export default ExperimentHistory; diff --git a/react-ui/src/pages/AutoML/components/ExperimentInstanceList/index.less b/react-ui/src/pages/AutoML/components/ExperimentInstanceList/index.less new file mode 100644 index 00000000..e91c0fae --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentInstanceList/index.less @@ -0,0 +1,65 @@ +@cellWidth: calc(100% + 32px + 33px - 48px - 200px - 344px); + +.tableExpandBox { + display: flex; + align-items: center; + width: 100%; + padding: 0 0 0 33px; + color: @text-color; + font-size: 14px; + + & > div { + padding: 0 16px; + } + + .check { + width: calc(@cellWidth * 3 / 20); // 15% + } + + .index { + width: calc(@cellWidth * 3 / 20); // 15% + } + + .description { + display: flex; + align-items: center; + width: calc(@cellWidth / 2); // 50% + } + + .startTime { + .singleLine(); + width: calc(@cellWidth / 5); // 20% + } + + .status { + width: 200px; + } + + .operation { + position: relative; + width: 344px; + } +} + +.tableExpandBoxContent { + height: 45px; + background-color: #fff; + border: 1px solid #eaeaea; + + & + & { + border-top: none; + } + + .statusBox { + display: flex; + align-items: center; + width: 200px; + } +} + +.loadMoreBox { + display: flex; + align-items: center; + justify-content: center; + margin: 16px auto 0; +} diff --git a/react-ui/src/pages/AutoML/components/ExperimentInstanceList/index.tsx b/react-ui/src/pages/AutoML/components/ExperimentInstanceList/index.tsx new file mode 100644 index 00000000..94d79aa9 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentInstanceList/index.tsx @@ -0,0 +1,240 @@ +import KFIcon from '@/components/KFIcon'; +import { ExperimentStatus } from '@/enums'; +import { useCheck } from '@/hooks/useCheck'; +import themes from '@/styles/theme.less'; +import { type ExperimentInstance } from '@/types'; +import { to } from '@/utils/promise'; +import { modalConfirm } from '@/utils/ui'; +import { DoubleRightOutlined } from '@ant-design/icons'; +import { App, Button, Checkbox, ConfigProvider } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useMemo } from 'react'; +import { ExperimentListType, experimentListConfig } from '../ExperimentList/config'; +import styles from './index.less'; +import ExperimentInstanceComponent from './instance'; + +type ExperimentInstanceListProps = { + type: ExperimentListType; + experimentInsList?: ExperimentInstance[]; + experimentInsTotal: number; + onClickInstance?: (instance: ExperimentInstance) => void; + onRemove?: () => void; + onTerminate?: (instance: ExperimentInstance) => void; + onLoadMore?: () => void; +}; + +function ExperimentInstanceList({ + type, + experimentInsList, + experimentInsTotal, + onClickInstance, + onRemove, + onTerminate, + onLoadMore, +}: ExperimentInstanceListProps) { + const { message } = App.useApp(); + const allIntanceIds = useMemo(() => { + return ( + experimentInsList + ?.filter( + (item) => + item.status !== ExperimentStatus.Running && item.status !== ExperimentStatus.Pending, + ) + .map((item) => item.id) || [] + ); + }, [experimentInsList]); + const [ + selectedIns, + setSelectedIns, + checked, + indeterminate, + checkAll, + isSingleChecked, + checkSingle, + ] = useCheck(allIntanceIds); + const config = experimentListConfig[type]; + + useEffect(() => { + // 关闭时清空 + if (allIntanceIds.length === 0) { + setSelectedIns([]); + } + }, [allIntanceIds, setSelectedIns]); + + // 删除实验实例确认 + const handleRemove = (instance: ExperimentInstance) => { + modalConfirm({ + title: '删除后,该实验实例将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteExperimentInstance(instance.id); + }, + }); + }; + + // 删除实验实例 + const deleteExperimentInstance = async (id: number) => { + const request = config.deleteInsReq; + const [res] = await to(request(id)); + if (res) { + message.success('删除成功'); + onRemove?.(); + } + }; + + // 批量删除实验实例确认 + const handleDeleteAll = () => { + modalConfirm({ + title: '确定批量删除选中的实例吗?', + onOk: () => { + batchDeleteExperimentInstances(); + }, + }); + }; + + // 批量删除实验实例 + const batchDeleteExperimentInstances = async () => { + const request = config.batchDeleteInsReq; + const [res] = await to(request(selectedIns)); + if (res) { + message.success('删除成功'); + setSelectedIns([]); + onRemove?.(); + } + }; + + // 终止实验实例 + const handleTerminate = (instance: ExperimentInstance) => { + modalConfirm({ + title: '终止后,该次实验运行将不可恢复', + content: '是否确认终止?', + isDelete: false, + onOk: () => { + terminateExperimentInstance(instance); + }, + }); + }; + + // 终止实验实例 + const terminateExperimentInstance = async (instance: ExperimentInstance) => { + const request = config.stopInsReq; + const [res] = await to(request(instance.id)); + if (res) { + message.success('终止成功'); + onTerminate?.(instance); + } + }; + + if (!experimentInsList || experimentInsList.length === 0) { + return
暂无数据
; + } + + return ( +
+
+
+ +
+
序号
+
运行时长
+
开始时间
+
状态
+
+ 操作 + {selectedIns.length > 0 && ( + + )} +
+
+ + {experimentInsList.map((item, index) => ( +
+
+ checkSingle(item.id)} + disabled={ + item.status === ExperimentStatus.Running || item.status === ExperimentStatus.Pending + } + > +
+ onClickInstance?.(item)} + > + {index + 1} + + +
+ + + + +
+
+ ))} + {experimentInsTotal > experimentInsList.length ? ( +
+ +
+ ) : null} +
+ ); +} + +export default ExperimentInstanceList; diff --git a/react-ui/src/pages/AutoML/components/ExperimentInstanceList/instance.tsx b/react-ui/src/pages/AutoML/components/ExperimentInstanceList/instance.tsx new file mode 100644 index 00000000..598e83a7 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentInstanceList/instance.tsx @@ -0,0 +1,71 @@ +import RunDuration from '@/components/RunDuration'; +import { ExperimentStatus } from '@/enums'; +import { useSSE, type MessageHandler } from '@/hooks/useSSE'; +import { experimentStatusInfo } from '@/pages/Experiment/status'; +import { ExperimentInstance, NodeStatus } from '@/types'; +import { ExperimentCompleted } from '@/utils/constant'; +import { formatDate } from '@/utils/date'; +import { getWorkflowStatus } from '@/utils/experiment'; +import { Typography } from 'antd'; +import React, { useCallback } from 'react'; +import styles from './index.less'; + +type ExperimentInstanceComponentProps = { + experimentId: number; + instance: ExperimentInstance; +}; + +function ExperimentInstanceComponent({ experimentId, instance }: ExperimentInstanceComponentProps) { + const { id, argo_ins_name, argo_ins_ns, node_status } = instance; + const workflowStatus = getWorkflowStatus(node_status) as NodeStatus | undefined; + const status = instance.status as ExperimentStatus; + const createTime = workflowStatus?.startedAt; + const finishTime = workflowStatus?.finishedAt; + const statusInfo = experimentStatusInfo[status]; + + const handleSSEMessage: MessageHandler = useCallback( + (experimentId: number, experimentInsId: number, status: string, finishTime: string) => { + window.postMessage({ + type: ExperimentCompleted, + payload: { + experimentId, + experimentInsId, + status, + finishTime, + }, + }); + }, + [], + ); + useSSE(experimentId, id, status, argo_ins_name, argo_ins_ns, handleSSEMessage); + + return ( + +
+ +
+
+ + {formatDate(createTime)} + +
+
+ {statusInfo ? ( + <> + + {statusInfo.label} + + ) : ( + '--' + )} +
+
+ ); +} + +export default ExperimentInstanceComponent; diff --git a/react-ui/src/pages/AutoML/components/ExperimentList/config.ts b/react-ui/src/pages/AutoML/components/ExperimentList/config.ts new file mode 100644 index 00000000..647b5c8c --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentList/config.ts @@ -0,0 +1,110 @@ +/* + * @Author: 赵伟 + * @Date: 2025-01-08 14:30:58 + * @Description: 实验列表组件配置 + */ + +import { + batchDeleteActiveLearnInsReq, + deleteActiveLearnInsReq, + deleteActiveLearnReq, + editActiveLearnInsReq, + getActiveLearnInsListReq, + getActiveLearnListReq, + runActiveLearnReq, + stopActiveLearnInsReq, +} from '@/services/activeLearn'; +import { + batchDeleteExperimentInsReq, + deleteAutoMLReq, + deleteExperimentInsReq, + editExperimentInsReq, + getAutoMLListReq, + getExperimentInsListReq, + runAutoMLReq, + stopExperimentInsReq, +} from '@/services/autoML'; +import { + batchDeleteRayInsReq, + deleteRayInsReq, + deleteRayReq, + editRayInsReq, + getRayInsListReq, + getRayListReq, + runRayReq, + stopRayInsReq, +} from '@/services/hyperParameter'; + +export enum ExperimentListType { + AutoML = 'AutoML', + HyperParameter = 'HyperParameter', + ActiveLearn = 'ActiveLearn', +} + +type ExperimentListInfo = { + getListReq: (params: any, skipLoading?: boolean) => Promise; // 获取列表 + getInsListReq: (params: any, skipLoading?: boolean) => Promise; // 获取实例列表 + deleteRecordReq: (params: any) => Promise; // 删除 + runRecordReq: (params: any) => Promise; // 运行 + deleteInsReq: (params: any) => Promise; // 删除实例 + batchDeleteInsReq: (params: any) => Promise; // 批量删除实例 + stopInsReq: (params: any) => Promise; // 终止实例 + editInsReq: (params: any) => Promise; // 编辑实例 + title: string; // 标题 + pathPrefix: string; // 路由路径前缀 + idProperty: string; // ID属性 + nameProperty: string; // 名称属性 + descProperty: string; // 描述属性 + idInsProperty: string; // 实例返回的ID属性 +}; + +export const experimentListConfig: Record = { + [ExperimentListType.AutoML]: { + getListReq: getAutoMLListReq, + getInsListReq: getExperimentInsListReq, + deleteRecordReq: deleteAutoMLReq, + runRecordReq: runAutoMLReq, + deleteInsReq: deleteExperimentInsReq, + batchDeleteInsReq: batchDeleteExperimentInsReq, + stopInsReq: stopExperimentInsReq, + editInsReq: editExperimentInsReq, + title: '自动机器学习', + pathPrefix: 'automl', + nameProperty: 'name', + descProperty: 'description', + idProperty: 'machineLearnId', + idInsProperty: 'machine_learn_id', + }, + [ExperimentListType.HyperParameter]: { + getListReq: getRayListReq, + getInsListReq: getRayInsListReq, + deleteRecordReq: deleteRayReq, + runRecordReq: runRayReq, + deleteInsReq: deleteRayInsReq, + batchDeleteInsReq: batchDeleteRayInsReq, + stopInsReq: stopRayInsReq, + editInsReq: editRayInsReq, + title: '超参数自动寻优', + pathPrefix: 'hyperparameter', + nameProperty: 'name', + descProperty: 'description', + idProperty: 'rayId', + idInsProperty: 'ray_id', + }, + [ExperimentListType.ActiveLearn]: { + getListReq: getActiveLearnListReq, + getInsListReq: getActiveLearnInsListReq, + deleteRecordReq: deleteActiveLearnReq, + runRecordReq: runActiveLearnReq, + deleteInsReq: deleteActiveLearnInsReq, + batchDeleteInsReq: batchDeleteActiveLearnInsReq, + stopInsReq: stopActiveLearnInsReq, + editInsReq: editActiveLearnInsReq, + title: '自动学习', + pathPrefix: 'active-learn', + nameProperty: 'name', + descProperty: 'description', + idProperty: 'activeLearnId', + idInsProperty: 'active_learn_id', + }, +}; diff --git a/react-ui/src/pages/AutoML/components/ExperimentList/index.less b/react-ui/src/pages/AutoML/components/ExperimentList/index.less new file mode 100644 index 00000000..fb00521e --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentList/index.less @@ -0,0 +1,20 @@ +.experiment-list { + height: 100%; + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 20px @content-padding 0; + background-color: white; + border-radius: 10px; + + &__filter { + display: flex; + align-items: center; + } + + &__table { + height: calc(100% - 32px - 28px); + margin-top: 28px; + } + } +} diff --git a/react-ui/src/pages/AutoML/components/ExperimentList/index.tsx b/react-ui/src/pages/AutoML/components/ExperimentList/index.tsx new file mode 100644 index 00000000..93a1562e --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentList/index.tsx @@ -0,0 +1,555 @@ +/* + * @Author: 赵伟 + * @Date: 2025-01-08 13:58:08 + * @Description: 自主机器学习和超参数寻优列表组件 + */ + +import KFIcon from '@/components/KFIcon'; +import PageTitle from '@/components/PageTitle'; +import { ExperimentStatus, autoMLTypeOptions } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { useServerTime } from '@/hooks/useServerTime'; +import { AutoMLData } from '@/pages/AutoML/types'; +import { experimentStatusInfo } from '@/pages/Experiment/status'; +import themes from '@/styles/theme.less'; +import { type ExperimentInstance as ExperimentInstanceData } from '@/types'; +import { ExperimentCompleted } from '@/utils/constant'; +import { to } from '@/utils/promise'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { modalConfirm } from '@/utils/ui'; +import { useNavigate } from '@umijs/max'; +import { + App, + Button, + ConfigProvider, + Input, + Table, + Tooltip, + type TablePaginationConfig, + type TableProps, +} from 'antd'; +import { type SearchProps } from 'antd/es/input'; +import classNames from 'classnames'; +import { useCallback, useEffect, useState } from 'react'; +import ExperimentInstanceList from '../ExperimentInstanceList'; +import { ExperimentListType, experimentListConfig } from './config'; +import styles from './index.less'; + +export { ExperimentListType }; + +type ExperimentListProps = { + type: ExperimentListType; +}; + +function ExperimentList({ type }: ExperimentListProps) { + const navigate = useNavigate(); + const { message } = App.useApp(); + const [cacheState, setCacheState] = useCacheState(); + const [searchText, setSearchText] = useState(cacheState?.searchText); + const [inputText, setInputText] = useState(cacheState?.searchText); + const [tableData, setTableData] = useState([]); + const [total, setTotal] = useState(0); + const [experimentInsList, setExperimentInsList] = useState([]); + const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [experimentInsTotal, setExperimentInsTotal] = useState(0); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 10, + }, + ); + const config = experimentListConfig[type]; + const [now] = useServerTime(); + + // 获取实验列表 + const getExperimentList = useCallback( + async (skipLoading: boolean = false) => { + const params: Record = { + page: pagination.current! - 1, + size: pagination.pageSize, + [config.nameProperty]: searchText || undefined, + }; + const request = config.getListReq; + const [res] = await to(request(params, skipLoading)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + setTableData(content); + setTotal(totalElements); + } + }, + [pagination, searchText, config], + ); + + // 获取实验实例列表 + const getExperimentInsList = useCallback( + async (recordId: number, page: number, size: number, skipLoading: boolean = false) => { + const params = { + [config.idProperty]: recordId, + page: page, + size: size, + }; + const request = config.getInsListReq; + const [res] = await to(request(params, skipLoading)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + try { + if (page === 0) { + setExperimentInsList(content); + } else { + setExperimentInsList((prev) => [...prev, ...content]); + } + setExperimentInsTotal(totalElements); + } catch (error) { + console.error('JSON parse error: ', error); + } + } + }, + [config.getInsListReq, config.idProperty], + ); + + // 刷新实验列表状态, + // TODO: 目前是直接刷新实验列表,后续需要优化,只刷新状态 + const refreshExperimentList = useCallback( + (skipLoading: boolean = false) => { + getExperimentList(skipLoading); + }, + [getExperimentList], + ); + + // 刷新实验实例列表 + const refreshExperimentIns = useCallback( + (experimentId: number, skipLoading: boolean = false) => { + const length = experimentInsList.length; + getExperimentInsList(experimentId, 0, length, skipLoading); + }, + [getExperimentInsList, experimentInsList], + ); + + // 更新实验实例状态 + const editExperimentIns = useCallback( + async ( + experimentId: number, + experimentInsId: number, + status: ExperimentStatus, + argo_ins_name: string, + argo_ins_ns: string, + ) => { + const params = { + [config.idInsProperty]: experimentId, + id: experimentInsId, + status: status, + argo_ins_name, + argo_ins_ns, + }; + const request = config.editInsReq; + const [res] = await to(request(params)); + if (res && res.data) { + refreshExperimentIns(experimentId, true); + refreshExperimentList(true); + } + }, + [config, refreshExperimentIns, refreshExperimentList], + ); + + // 获取实验列表 + useEffect(() => { + getExperimentList(); + }, [getExperimentList]); + + // expandedRowKeys 变化 + useEffect(() => { + if (expandedRowKeys.length > 0) { + getExperimentInsList(expandedRowKeys[0], 0, 5); + refreshExperimentList(); + } + }, [expandedRowKeys, getExperimentInsList, refreshExperimentList]); + + // 实验实例状态变化 + useEffect(() => { + const handleMessage = (e: MessageEvent) => { + const { type, payload } = e.data; + if (type === ExperimentCompleted) { + const { experimentId, experimentInsId, status /*finishTime*/ } = payload; + const currentIns = experimentInsList.find((v) => v.id === experimentInsId); + // console.log( + // '实验实例状态变化', + // currentIns?.status, + // status, + // experimentId, + // experimentInsId, + // finishTime, + // ); + + if ( + !currentIns || + currentIns.status === ExperimentStatus.Terminated || + currentIns.status === status + ) { + return; + } + + // refreshExperimentList(true); + // refreshExperimentIns(experimentId); + editExperimentIns( + experimentId, + experimentInsId, + status, + currentIns.argo_ins_name, + currentIns.argo_ins_ns, + ); + + // 修改实例的状态和结束时间 + // setExperimentInsList((prev) => + // prev.map((v) => + // v.id === experimentInsId + // ? { + // ...v, + // status: status, + // finish_time: finishTime, + // } + // : v, + // ), + // ); + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [experimentInsList, editExperimentIns]); + + // 搜索 + const onSearch: SearchProps['onSearch'] = (value) => { + setSearchText(value); + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + // 删除一条记录 + const deleteAutoML = async (record: AutoMLData) => { + const request = config.deleteRecordReq; + const [res] = await to(request(record.id)); + if (res) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: tableData.length === 1 ? Math.max(1, prev.current! - 1) : prev.current, + }; + }); + } + }; + + // 处理删除 + const handleAutoMLDelete = (record: AutoMLData) => { + modalConfirm({ + title: '删除后,该实验将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteAutoML(record); + }, + }); + }; + + // 创建、编辑、复制自动机器学习 + const createAutoML = (record?: AutoMLData, isCopy: boolean = false) => { + setCacheState({ + pagination, + searchText, + expandedRowKeys, + }); + + if (record) { + if (isCopy) { + navigate(`copy/${record.id}`); + } else { + navigate(`edit/${record.id}`); + } + } else { + navigate(`create`); + } + }; + + // 查看自动机器学习详情 + const gotoDetail = (record: AutoMLData) => { + setCacheState({ + pagination, + searchText, + expandedRowKeys, + }); + + navigate(`info/${record.id}`); + }; + + // 启动自动机器学习 + const startAutoML = async (record: AutoMLData) => { + const request = config.runRecordReq; + const [res] = await to(request(record.id)); + if (res) { + message.success('运行成功'); + setExpandedRowKeys([record.id]); + // getExperimentInsList(record.id, 0, 5); + // refreshExperimentList(); + } + }; + + // --------------------------- 实验实例 --------------------------- + // 展开实例 + const handleExpandChange = (expanded: boolean, record: AutoMLData) => { + setExperimentInsList([]); + if (expanded) { + setExpandedRowKeys([record.id]); + // getExperimentInsList(record.id, 0, 5); + // refreshExperimentList(); + } else { + setExpandedRowKeys([]); + } + }; + + // 跳转到实验实例详情 + const gotoInstanceInfo = (autoML: AutoMLData, record: ExperimentInstanceData) => { + setCacheState({ + pagination, + searchText, + expandedRowKeys, + }); + navigate(`instance/${autoML.id}/${record.id}`); + }; + + // 加载更多实验实例 + const loadMoreExperimentIns = () => { + const page = Math.round(experimentInsList.length / 5); + const recordId = expandedRowKeys[0]; + getExperimentInsList(recordId, page, 5); + }; + + // 实验实例终止 + const handleInstanceTerminate = async (experimentIns: ExperimentInstanceData) => { + // 修改实例的状态和结束时间 + setExperimentInsList((prevList) => { + return prevList.map((item) => { + if (item.id === experimentIns.id) { + return { + ...item, + status: ExperimentStatus.Terminated, + finish_time: now().toISOString(), + }; + } + return item; + }); + }); + // 刷新实验列表和实例列表 + refreshExperimentList(true); + if (expandedRowKeys.length > 0) { + refreshExperimentIns(expandedRowKeys[0]); + } + }; + // --------------------------- Table --------------------------- + // 分页切换 + const handleTableChange: TableProps['onChange'] = ( + pagination, + _filters, + _sorter, + { action }, + ) => { + if (action === 'paginate') { + setPagination(pagination); + } + }; + + const typeColumns = [ + { + title: '类型', + dataIndex: 'type', + key: 'type', + width: '15%', + render: tableCellRender(false, TableCellValueType.Enum, { + options: autoMLTypeOptions, + }), + }, + ]; + + const diffColumns = type === ExperimentListType.AutoML ? typeColumns : []; + + const columns: TableProps['columns'] = [ + { + title: '实验名称', + dataIndex: config.nameProperty, + key: 'name', + width: '30%', + render: tableCellRender(false, TableCellValueType.Link, { + onClick: gotoDetail, + }), + }, + { + title: '实验描述', + dataIndex: config.descProperty, + key: 'description', + render: tableCellRender(true), + width: type === ExperimentListType.AutoML ? '35%' : '50%', + }, + ...diffColumns, + { + title: '更新时间', + dataIndex: 'update_time', + key: 'update_time', + width: '20%', + render: tableCellRender(true, TableCellValueType.Date), + }, + { + title: '最近五次运行状态', + dataIndex: 'status_list', + key: 'status_list', + width: 200, + render: (text) => { + const newText: string[] = text && text.replace(/\s+/g, '').split(','); + return ( + <> + {newText && newText.length > 0 + ? newText.map((item, index) => { + return ( + + + + ); + }) + : null} + + ); + }, + }, + { + title: '操作', + dataIndex: 'operation', + width: 360, + key: 'operation', + render: (_: any, record: AutoMLData) => ( +
+ + + + + + + +
+ ), + }, + ]; + + return ( +
+ +
+
+ setInputText(e.target.value)} + style={{ width: 300 }} + value={inputText} + allowClear + /> + +
+
+
`共${total}条`, + }} + onChange={handleTableChange} + expandable={{ + expandedRowRender: (record) => ( + gotoInstanceInfo(record, item)} + onRemove={() => { + refreshExperimentIns(record.id); + refreshExperimentList(); + }} + onTerminate={handleInstanceTerminate} + onLoadMore={() => loadMoreExperimentIns()} + > + ), + onExpand: handleExpandChange, + expandedRowKeys: expandedRowKeys, + }} + rowKey="id" + /> + + + + ); +} + +export default ExperimentList; diff --git a/react-ui/src/pages/AutoML/components/ExperimentLog/empty.tsx b/react-ui/src/pages/AutoML/components/ExperimentLog/empty.tsx new file mode 100644 index 00000000..219a8710 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentLog/empty.tsx @@ -0,0 +1,7 @@ +import styles from './index.less'; + +function EmptyLog() { + return
暂无日志
; +} + +export default EmptyLog; diff --git a/react-ui/src/pages/AutoML/components/ExperimentLog/index.less b/react-ui/src/pages/AutoML/components/ExperimentLog/index.less new file mode 100644 index 00000000..d0c76f5b --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentLog/index.less @@ -0,0 +1,18 @@ +.experiment-log { + height: 100%; + + &__log { + height: 100%; + } +} + +.empty-log { + height: 100%; + padding: 15px; + color: white; + font-size: 14px; + white-space: pre-line; + text-align: center; + word-break: break-all; + background: #19253b; +} diff --git a/react-ui/src/pages/AutoML/components/ExperimentLog/index.tsx b/react-ui/src/pages/AutoML/components/ExperimentLog/index.tsx new file mode 100644 index 00000000..157aae80 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentLog/index.tsx @@ -0,0 +1,40 @@ +import { ExperimentStatus } from '@/enums'; +import { AutoMLInstanceData } from '@/pages/AutoML/types'; +import LogList from '@/pages/Experiment/components/LogList'; +import { NodeStatus } from '@/types'; +import EmptyLog from './empty'; +import styles from './index.less'; + +const NodePrefix = 'auto-ml'; + +type ExperimentLogProps = { + instanceInfo: AutoMLInstanceData; + nodes: Record; +}; + +function ExperimentLog({ instanceInfo, nodes }: ExperimentLogProps) { + const nodeStatus: NodeStatus | undefined = Object.values(nodes).find((node: any) => + node.displayName.startsWith(NodePrefix), + ) as NodeStatus; + + return ( +
+
+ {nodeStatus ? ( + + ) : ( + + )} +
+
+ ); +} + +export default ExperimentLog; diff --git a/react-ui/src/pages/AutoML/components/ExperimentResult/index.less b/react-ui/src/pages/AutoML/components/ExperimentResult/index.less new file mode 100644 index 00000000..87d1e38d --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentResult/index.less @@ -0,0 +1,63 @@ +.experiment-result { + height: calc(100% - 10px); + margin-top: 10px; + padding: 20px @content-padding; + overflow-y: auto; + background-color: white; + border-radius: 10px; + + &__download { + padding-top: 16px; + padding-bottom: 16px; + margin-top: 16px; + padding-left: @content-padding; + color: @text-color; + font-size: 13px; + background-color: #f8f8f9; + border-radius: 4px; + + &__btn { + display: block; + height: 36px; + margin-top: 15px; + font-size: 14px; + } + } + + &__file { + height: 100%; + :global { + .kf-info-group__content { + height: calc(100% - 56px); + overflow-y: auto; + } + } + } + + &__text { + font-family: 'Roboto Mono', 'Menlo', 'Consolas', 'Monaco', monospace; + white-space: pre; + } + + &__images { + display: flex; + align-items: flex-start; + width: 100%; + overflow-x: auto; + + :global { + .ant-image { + margin-right: 20px; + + &:last-child { + margin-right: 0; + } + } + } + + &__item { + height: 248px; + border: 1px solid rgba(96, 107, 122, 0.3); + } + } +} diff --git a/react-ui/src/pages/AutoML/components/ExperimentResult/index.tsx b/react-ui/src/pages/AutoML/components/ExperimentResult/index.tsx new file mode 100644 index 00000000..bc21edf4 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentResult/index.tsx @@ -0,0 +1,100 @@ +import InfoGroup from '@/components/InfoGroup'; +import { AutoMLType } from '@/enums'; +import { type HyperParameterFile } from '@/pages/AutoML/types'; +import TrialFileTree from '@/pages/HyperParameter/components/TrialFileTree'; +import { getFileReq } from '@/services/file'; +import { to } from '@/utils/promise'; +import { Button, Image } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import styles from './index.less'; + +type ExperimentResultProps = { + fileUrl?: string; + imageUrl?: string; + modelPath?: string; + type?: string; + file?: HyperParameterFile; +}; + +function ExperimentResult({ fileUrl, imageUrl, modelPath, type, file }: ExperimentResultProps) { + const [result, setResult] = useState(''); + + const images = useMemo(() => { + if (imageUrl) { + return imageUrl.split(',').map((item) => item.trim()); + } + return []; + }, [imageUrl]); + + useEffect(() => { + // 获取实验运行历史记录 + const getResultFile = async () => { + const [res] = await to(getFileReq(fileUrl)); + if (res) { + setResult(res as any as string); + } + }; + + if (fileUrl && type !== AutoMLType.Video) { + getResultFile(); + } + }, [fileUrl, type]); + + return ( +
+ {type === AutoMLType.Video ? ( + + ) : ( + +
{result}
+
+ )} + + {type === AutoMLType.Table && ( + +
+ + console.log(`current index: ${current}, prev index: ${prev}`), + }} + > + {images.map((item) => ( + + ))} + +
+
+ )} + + {modelPath && ( +
+ 文件名 + {modelPath.split('/').pop()} + +
+ )} +
+ ); +} + +export default ExperimentResult; diff --git a/react-ui/src/pages/AutoML/components/ExperimentRunBasic/index.tsx b/react-ui/src/pages/AutoML/components/ExperimentRunBasic/index.tsx new file mode 100644 index 00000000..14ec8207 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentRunBasic/index.tsx @@ -0,0 +1,72 @@ +import ConfigInfo from '@/components/ConfigInfo'; +import RunDuration from '@/components/RunDuration'; +import { ExperimentStatus } from '@/enums'; +import { experimentStatusInfo } from '@/pages/Experiment/status'; +import { type NodeStatus } from '@/types'; +import { getExperimentInstanceStatus } from '@/utils/experiment'; +import { formatDate } from '@/utils/format'; +import { Flex } from 'antd'; +import { useMemo } from 'react'; + +type ExperimentRunBasicProps = { + workflowStatus?: NodeStatus; + instanceStatus?: ExperimentStatus; +}; + +function ExperimentRunBasic({ workflowStatus, instanceStatus }: ExperimentRunBasicProps) { + const instanceDatas = useMemo(() => { + const status = getExperimentInstanceStatus(instanceStatus as ExperimentStatus, workflowStatus); + const statusInfo = experimentStatusInfo[status]; + + return [ + { + label: '启动时间', + value: formatDate(workflowStatus?.startedAt), + }, + { + label: '执行时长', + value: ( + + ), + }, + { + label: '状态', + value: statusInfo ? ( + + +
+ {statusInfo.label} +
+
+ ) : ( + '--' + ), + }, + ]; + }, [workflowStatus, instanceStatus]); + + return ( + + ); +} + +export default ExperimentRunBasic; diff --git a/react-ui/src/pages/AutoML/components/ExperimentVisualResult/index.less b/react-ui/src/pages/AutoML/components/ExperimentVisualResult/index.less new file mode 100644 index 00000000..2cf61f91 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentVisualResult/index.less @@ -0,0 +1,8 @@ +.experiment-visual { + width: 100%; + height: 100%; + + &__empty { + height: 100%; + } +} diff --git a/react-ui/src/pages/AutoML/components/ExperimentVisualResult/index.tsx b/react-ui/src/pages/AutoML/components/ExperimentVisualResult/index.tsx new file mode 100644 index 00000000..26ebbeca --- /dev/null +++ b/react-ui/src/pages/AutoML/components/ExperimentVisualResult/index.tsx @@ -0,0 +1,95 @@ +/* + * @Author: 赵伟 + * @Date: 2024-09-02 08:42:57 + * @Description: 可视化 + */ + +import IframePage, { IframePageType } from '@/components/IFramePage'; +import KFEmpty, { EmptyType } from '@/components/KFEmpty'; +import KFSpin from '@/components/KFSpin'; +import { TensorBoardStatus } from '@/enums'; +import { getTensorBoardStatusReq, runTensorBoardReq } from '@/services/experiment/index.js'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import { LoadingOutlined } from '@ant-design/icons'; +import { useCallback, useEffect, useState } from 'react'; +import styles from './index.less'; + +type TensorBoardProps = { + namespace?: string; + path?: string; +}; + +function ExperimentVisualResult({ namespace, path }: TensorBoardProps) { + const [tensorboardUrl, setTensorboardUrl] = useState(undefined); + const [status, setStatus] = useState(undefined); + + // 获取 TensorBoard 状态 + const getTensorBoardStatus = useCallback(async () => { + const params = { + namespace: namespace, + path: path, + }; + const [res] = await to(getTensorBoardStatusReq(params)); + if (res && res.data) { + const status = res.data.status as TensorBoardStatus | undefined; + setStatus(res.data.status); + if (!status || status === TensorBoardStatus.Pending) { + setTimeout(() => { + getTensorBoardStatus(); + }, 5000); + } + } + }, [namespace, path]); + + // 运行 TensorBoard + const runTensorBoard = useCallback(async () => { + const params = { + namespace: namespace, + path: path, + }; + const [res] = await to(runTensorBoardReq(params)); + if (res && res.data) { + const url = res.data; + SessionStorage.setItem(SessionStorage.tensorBoardUrlKey, url); + setTensorboardUrl(url); + getTensorBoardStatus(); + } else { + setTensorboardUrl(null); + } + }, [namespace, path, getTensorBoardStatus]); + + useEffect(() => { + if (namespace && path) { + runTensorBoard(); + } + }, [namespace, path, runTensorBoard]); + + if (tensorboardUrl === null || status === TensorBoardStatus.Failed) { + return ( +
+ +
+ ); + } else if (status === TensorBoardStatus.Pending) { + return ( +
+ } size="large" /> +
+ ); + } else if (status === TensorBoardStatus.Running) { + return ( +
+ +
+ ); + } +} + +export default ExperimentVisualResult; diff --git a/react-ui/src/pages/AutoML/components/TrialStatusCell/index.less b/react-ui/src/pages/AutoML/components/TrialStatusCell/index.less new file mode 100644 index 00000000..6bdaf5bc --- /dev/null +++ b/react-ui/src/pages/AutoML/components/TrialStatusCell/index.less @@ -0,0 +1,3 @@ +.trial-status-cell { + height: 100%; +} diff --git a/react-ui/src/pages/AutoML/components/TrialStatusCell/index.tsx b/react-ui/src/pages/AutoML/components/TrialStatusCell/index.tsx new file mode 100644 index 00000000..433a007e --- /dev/null +++ b/react-ui/src/pages/AutoML/components/TrialStatusCell/index.tsx @@ -0,0 +1,72 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-18 18:35:41 + * @Description: 实验状态 + */ + +import { AutoMLTrailStatus } from '@/enums'; +import { ExperimentStatusInfo } from '@/pages/Experiment/status'; +import themes from '@/styles/theme.less'; +import styles from './index.less'; + +export const statusInfo: Record = { + [AutoMLTrailStatus.SUCCESS]: { + label: '成功', + color: themes.successColor, + icon: '/assets/images/experiment-status/success-icon.png', + }, + [AutoMLTrailStatus.TIMEOUT]: { + label: '超时', + color: themes.pendingColor, + icon: '/assets/images/experiment-status/pending-icon.png', + }, + [AutoMLTrailStatus.FAILURE]: { + label: '失败', + color: themes.errorColor, + icon: '/assets/images/experiment-status/fail-icon.png', + }, + [AutoMLTrailStatus.CRASHED]: { + label: '崩溃', + color: themes.errorColor, + icon: '/assets/images/experiment-status/fail-icon.png', + }, + [AutoMLTrailStatus.CANCELLED]: { + label: '取消', + color: themes.abortColor, + icon: '/assets/images/experiment-status/omitted-icon.png', + }, + [AutoMLTrailStatus.STOP]: { + label: '停止', + color: themes.textColor, + icon: '/assets/images/experiment-status/omitted-icon.png', + }, + [AutoMLTrailStatus.MEMOUT]: { + label: '内存溢出', + color: themes.errorColor, + icon: '/assets/images/experiment-status/fail-icon.png', + }, +}; + +function TrialStatusCell(status?: AutoMLTrailStatus | null) { + if (status === null || status === undefined) { + return --; + } + return ( +
+ {/* */} + + {statusInfo[status] ? statusInfo[status].label : '未知'} + +
+ ); +} + +export default TrialStatusCell; diff --git a/react-ui/src/pages/AutoML/types.ts b/react-ui/src/pages/AutoML/types.ts new file mode 100644 index 00000000..dc24600f --- /dev/null +++ b/react-ui/src/pages/AutoML/types.ts @@ -0,0 +1,103 @@ +import { type ParameterInputObject } from '@/components/ResourceSelect'; +import { type NodeStatus } from '@/types'; +import { type HyperParameterFile } from '@/pages/HyperParameter/types'; + +export { type HyperParameterFile } + +// 操作类型 +export enum OperationType { + Create = 'Create', // 创建 + Update = 'Update', // 更新 +} + +// 表单数据 +export type FormData = { + name: string; // 实验名称 + description: string; // 实验描述 + type: string; // 类型 + ensemble_class?: string; // 集成方式 + ensemble_nbest?: string; // 集成最佳模型数量 + ensemble_size?: number; // 集成模型数量 + include_classifier?: string[]; // 分类算法 + include_feature_preprocessor?: string[]; // 特征预处理算法 + include_regressor?: string[]; // 回归算法 + exclude_classifier?: string[]; + exclude_feature_preprocessor?: string[]; + exclude_regressor?: string[]; + max_models_on_disc?: number; // 最大数量 + memory_limit?: number; // 内存限制(MB) + per_run_time_limit?: number; // 时间限制(秒) + resampling_strategy?: string; // 重采样策略 + folds?: number; // 交叉验证折数 + scoring_functions?: string; // 计算指标 + shuffle?: boolean; // 是否打乱 + seed?: number; // 随机种子 + task_type: string; // 任务类型 + test_size?: number; // 测试集比率 + train_size?: number; // 训练集比率 + time_left_for_this_task: number; // 搜索时间限制(秒) + metric_name?: string; // 指标名称 + greater_is_better: boolean; // 指标优化方向 + metrics?: { name: string; value: number }[]; // 指标权重 + dataset: ParameterInputObject; // 数据集 + target_columns: string; // 预测目标列 + model_type?: string; // 文本模型 + computing_resource_id?: number; // 资源规格 + batch_size?: number; + epochs?: number; + lr?: number; + num_classes?: number; + is_validate?: boolean; + train_data_prefix?: string; + valid_data_prefix?: string; + train_file_path?: string; + valid_file_path?: string; +}; + +export type AutoMLData = { + id: number; + progress: number; + run_state: string; + state: number; + metrics?: string; + include_classifier?: string; + include_feature_preprocessor?: string; + include_regressor?: string; + exclude_classifier?: string; + exclude_feature_preprocessor?: string; + exclude_regressor?: string; + dataset?: string; + create_by?: string; + create_time?: string; + update_by?: string; + update_time?: string; + status_list: string; // 最近五次运行状态 + param: string; // 参数json字符串 +} & Omit< + FormData, + 'metrics|dataset|include_classifier|include_feature_preprocessor|include_regressor|exclude_classifier|exclude_feature_preprocessor|exclude_regressor' +>; + +// 自动机器学习实验实例 +export type AutoMLInstanceData = { + id: number; + auto_ml_id: number; + result_path: string; + model_path: string; + img_path: string; + run_history_path: string; + state: number; + status: string; + node_status: string; + node_result: string; + param: string; + source: string | null; + argo_ins_name: string; + argo_ins_ns: string; + create_time: string; + update_time: string; + finish_time: string; + nodeStatus?: NodeStatus; + type: string; + file_map?: HyperParameterFile; +}; diff --git a/react-ui/src/pages/CodeConfig/List/index.less b/react-ui/src/pages/CodeConfig/List/index.less new file mode 100644 index 00000000..d4cb1b4a --- /dev/null +++ b/react-ui/src/pages/CodeConfig/List/index.less @@ -0,0 +1,46 @@ +.code-config { + height: 100%; + + &__list { + display: flex; + flex-direction: column; + height: calc(100% - 60px); + margin-top: 10px; + padding: 30px 30px 0; + background: white; + border-radius: 10px; + box-shadow: 0px 2px 12px rgba(180, 182, 191, 0.09); + + &__header { + display: flex; + align-items: center; + height: 32px; + color: @text-color; + font-size: 15px; + } + + &__content { + display: flex; + flex: 1 1 0%; + flex-wrap: wrap; + gap: 20px; + align-content: flex-start; + width: 100%; + margin: 25px 0; + overflow-y: auto; + } + + &__empty { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + } + + :global { + .ant-pagination { + margin-bottom: 25px; + } + } + } +} diff --git a/react-ui/src/pages/CodeConfig/List/index.tsx b/react-ui/src/pages/CodeConfig/List/index.tsx new file mode 100644 index 00000000..069cdbe8 --- /dev/null +++ b/react-ui/src/pages/CodeConfig/List/index.tsx @@ -0,0 +1,215 @@ +/* + * @Author: 赵伟 + * @Date: 2024-10-10 09:55:12 + * @Description: 代码配置 + */ + +import KFEmpty, { EmptyType } from '@/components/KFEmpty'; +import KFIcon from '@/components/KFIcon'; +import PageTitle from '@/components/PageTitle'; +import { deleteCodeConfigReq, getCodeConfigListReq } from '@/services/codeConfig'; +import { getGitUrl } from '@/utils'; +import { openAntdModal } from '@/utils/modal'; +import { to } from '@/utils/promise'; +import { modalConfirm } from '@/utils/ui'; +import { App, Button, Input, Pagination, PaginationProps } from 'antd'; +import { useCallback, useEffect, useState } from 'react'; +import AddCodeConfigModal, { OperationType } from '../components/AddCodeConfigModal'; +import CodeConfigItem from '../components/CodeConfigItem'; +import styles from './index.less'; + +// 代码配置数据 +export type CodeConfigData = { + id: number; + code_repo_name: string; + is_public: boolean; + git_url: string; + git_branch: string; + git_user_name: string; + git_password: string; + ssh_key: string; + verify_mode: number; + create_by: string; + create_time: string; + update_by: string; + update_time: string; +}; + +export type ResourceListRef = { + reset: () => void; +}; + +function CodeConfigList() { + const [dataList, setDataList] = useState(undefined); + const [total, setTotal] = useState(0); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 20, + }); + const [searchText, setSearchText] = useState(undefined); + const [inputText, setInputText] = useState(undefined); + const { message } = App.useApp(); + + // 获取数据请求 + const getDataList = useCallback(async () => { + const params = { + page: pagination.current! - 1, + size: pagination.pageSize, + code_repo_name: searchText || undefined, + }; + const [res] = await to(getCodeConfigListReq(params)); + if (res && res.data && res.data.content) { + setDataList(res.data.content); + setTotal(res.data.totalElements); + } else { + setDataList([]); + setTotal(0); + } + }, [pagination, searchText]); + + useEffect(() => { + getDataList(); + }, [getDataList]); + + // 删除请求 + const deleteRecord = async (id: number) => { + const [res] = await to(deleteCodeConfigReq(id)); + if (res) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: dataList!.length === 1 ? Math.max(1, prev.current! - 1) : prev.current, + }; + }); + } + }; + + // 搜索 + const handleSearch = (value: string) => { + setSearchText(value); + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + // 删除 + const handleRemove = (record: CodeConfigData) => { + modalConfirm({ + title: '确定删除这个代码配置吗?', + onOk: () => { + deleteRecord(record.id); + }, + }); + }; + + // 修改 + const handleEdit = (record: CodeConfigData) => { + const { close } = openAntdModal(AddCodeConfigModal, { + opType: OperationType.Update, + codeConfigData: record, + onOk: () => { + getDataList(); + close(); + }, + }); + }; + + // 查看 + const handleClick = (record: CodeConfigData) => { + const { git_url, git_branch } = record; + const url = getGitUrl(git_url, git_branch); + window.open(url, '_blank'); + }; + + // 新建 + const createCodeConfig = () => { + const { close } = openAntdModal(AddCodeConfigModal, { + opType: OperationType.Create, + onOk: () => { + getDataList(); + close(); + }, + }); + }; + + // 分页切换 + const handlePageChange: PaginationProps['onChange'] = (page, pageSize) => { + setPagination({ + current: page, + pageSize: pageSize, + }); + }; + + return ( +
+ +
+
+ 数据总数:{total} 个 + setInputText(e.target.value)} + value={inputText} + /> + +
+ + {dataList && dataList.length !== 0 && ( + <> +
+ {dataList.map((item) => ( + + ))} +
+ + + )} + {dataList && dataList.length === 0 && ( + + )} +
+
+ ); +} + +export default CodeConfigList; diff --git a/react-ui/src/pages/CodeConfig/components/AddCodeConfigModal/index.less b/react-ui/src/pages/CodeConfig/components/AddCodeConfigModal/index.less new file mode 100644 index 00000000..e69de29b diff --git a/react-ui/src/pages/CodeConfig/components/AddCodeConfigModal/index.tsx b/react-ui/src/pages/CodeConfig/components/AddCodeConfigModal/index.tsx new file mode 100644 index 00000000..c250547a --- /dev/null +++ b/react-ui/src/pages/CodeConfig/components/AddCodeConfigModal/index.tsx @@ -0,0 +1,269 @@ +import KFModal from '@/components/KFModal'; +import { type CodeConfigData } from '@/pages/CodeConfig/List'; +import { addCodeConfigReq, updateCodeConfigReq } from '@/services/codeConfig'; +import { to } from '@/utils/promise'; +import { Form, Input, Radio, message, type FormRule, type ModalProps } from 'antd'; +import { omit } from 'lodash'; +import { useMemo } from 'react'; + +export enum VerifyMode { + Password = 0, // 用户名密码 + SSH = 1, // SSH Key +} + +export enum OperationType { + Create = 0, // 新建 + Update = 1, // 更新 +} + +type FormData = Partial; + +interface AddCodeConfigModalProps extends Omit { + opType: OperationType; + codeConfigData?: CodeConfigData; + onOk: () => void; +} + +function AddCodeConfigModal({ opType, codeConfigData, onOk, ...rest }: AddCodeConfigModalProps) { + const [form] = Form.useForm(); + const isPublic = Form.useWatch('is_public', form) as boolean; + + const urlExample = useMemo( + () => + isPublic + ? 'https://gitlink.org.cn/ci4s/ci4sManagement-cloud.git' + : 'git@code.gitlink.org.cn:ci4s/ci4sManagement-cloud.git', + [isPublic], + ); + + // /^(git@[\w.-]+:[\w./-]+\.git)$/ + const urlRules: FormRule[] = useMemo( + () => + isPublic + ? [ + { + type: 'url', + message: '请输入正确的 Git 地址', + }, + ] + : ([] as FormRule[]), + [isPublic], + ); + + // 创建 + const createCodeConfig = async (formData: FormData) => { + const params: FormData & { id?: number } = { + ...formData, + }; + // 清除多余的信息 + if (formData.is_public) { + omit(params, ['verify_mode', 'git_user_name', 'git_password', 'ssh_key']); + } + if (formData.verify_mode === VerifyMode.Password) { + omit(params, ['ssh_key']); + } else if (formData.verify_mode === VerifyMode.SSH) { + omit(params, ['git_user_name', 'git_password']); + } + if (opType === OperationType.Update) { + params.id = codeConfigData?.id; + } + const request = opType === OperationType.Create ? addCodeConfigReq : updateCodeConfigReq; + const [res] = await to(request(params)); + if (res) { + message.success(opType === OperationType.Create ? '创建成功' : '修改成功'); + onOk?.(); + } + }; + + // 提交 + const onFinish = (formData: FormData) => { + createCodeConfig(formData); + }; + + // 设置初始值 + const initialValues: FormData = codeConfigData ?? { + is_public: true, + verify_mode: VerifyMode.Password, + }; + if (initialValues.verify_mode === undefined || initialValues.verify_mode === null) { + initialValues.verify_mode = VerifyMode.Password; + } + + return ( + +
+ + + + + + 公开 + 私有 + + + + + + + + + + prevValues?.is_public !== currentValues?.is_public + } + > + {({ getFieldValue }) => { + return getFieldValue('is_public') === false ? ( + <> + + + 用户名/密码 + SSH Key + + + + prevValues?.verify_mode !== currentValues?.verify_mode + } + > + {({ getFieldValue }) => { + return getFieldValue('verify_mode') === VerifyMode.Password ? ( + <> + + + + + + + + ) : ( + + + + ); + }} + + + ) : null; + }} + + +
+ ); +} + +export default AddCodeConfigModal; diff --git a/react-ui/src/pages/CodeConfig/components/CodeConfigItem/index.less b/react-ui/src/pages/CodeConfig/components/CodeConfigItem/index.less new file mode 100644 index 00000000..5af10af8 --- /dev/null +++ b/react-ui/src/pages/CodeConfig/components/CodeConfigItem/index.less @@ -0,0 +1,109 @@ +.code-config-item { + position: relative; + width: calc(25% - 15px); + padding: 20px; + background: linear-gradient(180deg, #f7faff 0%, #ffffff 100%); + border: 2px solid white; + border-radius: 4px; + box-shadow: 0px 3px 10px rgba(164, 169, 181, 0.13); + cursor: pointer; + + &:hover { + border-color: @primary-color; + } + + @media screen and (max-width: 1860px) { + & { + width: calc(33.33% - 13.33px); + } + } + + &__icon { + flex: none; + width: 16px; + height: 16px; + margin-right: 10px; + } + + &__name { + position: relative; + margin-right: 20px; + margin-bottom: 0 !important; + color: @text-color; + font-weight: 500; + font-size: 16px; + + &::after { + position: absolute; + top: 14px; + left: 0; + width: 100%; + height: 6px; + background: linear-gradient( + to right, + .addAlpha(@primary-color, 0.4) [] 0, + .addAlpha(@primary-color, 0) [] 100% + ); + content: ''; + } + } + + &:hover &__name { + color: @primary-color; + } + + &__tag { + flex: none; + padding: 1px 10px; + font-size: 13px; + border-radius: 2px; + + &--public { + color: @primary-color; + background-color: .addAlpha(@primary-color, 0.1) []; + border: 1px solid .addAlpha(@primary-color, 0.5) []; + } + + &--private { + color: @warning-color; + background-color: .addAlpha(@warning-color, 0.1) []; + border: 1px solid .addAlpha(@warning-color, 0.5) []; + } + } + + :global { + .ant-btn { + flex: none; + color: #808080; + } + } + + &__url-box { + margin-bottom: 15px; + padding: 14px; + background-color: .addAlpha(@primary-color, 0.04) []; + border-radius: 4px; + } + + &__url { + margin-bottom: 15px !important; + color: @text-color; + font-size: 14px; + word-break: break-all; + } + + &__branch { + color: @text-color-secondary; + font-size: 14px; + } + + &__user, + &__time { + display: flex; + flex: 0 1 content; + align-items: center; + width: 100%; + color: #808080; + font-size: 13px; + } +} diff --git a/react-ui/src/pages/CodeConfig/components/CodeConfigItem/index.tsx b/react-ui/src/pages/CodeConfig/components/CodeConfigItem/index.tsx new file mode 100644 index 00000000..fde00268 --- /dev/null +++ b/react-ui/src/pages/CodeConfig/components/CodeConfigItem/index.tsx @@ -0,0 +1,98 @@ +import clock from '@/assets/img/clock.png'; +import creatByImg from '@/assets/img/creatBy.png'; +import KFIcon from '@/components/KFIcon'; +import { type CodeConfigData } from '@/pages/CodeConfig/List'; +import { formatDate } from '@/utils/date'; +import { Button, Flex, Typography } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; + +type CodeConfigItemProps = { + item: CodeConfigData; + onClick?: (item: CodeConfigData) => void; + onEdit?: (item: CodeConfigData) => void; + onRemove?: (item: CodeConfigData) => void; +}; + +function CodeConfigItem({ item, onClick, onEdit, onRemove }: CodeConfigItemProps) { + return ( +
onClick?.(item)}> + + + + {item.code_repo_name} + +
+ {item.is_public ? '公开' : '私有'} +
+ + +
+
+ + {item.git_url} + + + {item.git_branch} + +
+ +
+ + {item.create_by} +
+
+ + 最近更新: {formatDate(item.update_time, 'YYYY-MM-DD')} +
+
+
+ ); +} + +export default CodeConfigItem; diff --git a/react-ui/src/pages/Dataset/components/AddDatasetModal/index.less b/react-ui/src/pages/Dataset/components/AddDatasetModal/index.less new file mode 100644 index 00000000..428395bd --- /dev/null +++ b/react-ui/src/pages/Dataset/components/AddDatasetModal/index.less @@ -0,0 +1,10 @@ +.upload-tip { + margin-top: 5px; + color: @text-color-secondary; + font-size: 14px; +} + +.upload-button { + height: 46px; + font-size: 15px; +} diff --git a/react-ui/src/pages/Dataset/components/AddDatasetModal/index.tsx b/react-ui/src/pages/Dataset/components/AddDatasetModal/index.tsx new file mode 100644 index 00000000..ea8f9765 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/AddDatasetModal/index.tsx @@ -0,0 +1,117 @@ +import KFModal from '@/components/KFModal'; +import { CategoryData, DataSource } from '@/pages/Dataset/config'; +import { addDataset } from '@/services/dataset/index.js'; +import { to } from '@/utils/promise'; +import { Form, Input, Radio, Select, message, type ModalProps } from 'antd'; + +interface AddDatasetModalProps extends Omit { + typeList: CategoryData[]; + tagList: CategoryData[]; + onOk: () => void; +} + +function AddDatasetModal({ typeList, tagList, onOk, ...rest }: AddDatasetModalProps) { + // 上传请求 + const createDataset = async (params: any) => { + const [res] = await to(addDataset(params)); + if (res) { + message.success('创建成功'); + onOk?.(); + } + }; + + // 提交 + const onFinish = (formData: any) => { + const params = { + ...formData, + dataset_source: DataSource.Create, + }; + createDataset(params); + }; + + return ( + +
+ + + + + + + + + + + + 私有 + 公开 + + + +
+ ); +} + +export default AddDatasetModal; diff --git a/react-ui/src/pages/Dataset/components/AddModelModal/index.tsx b/react-ui/src/pages/Dataset/components/AddModelModal/index.tsx new file mode 100644 index 00000000..85de87ac --- /dev/null +++ b/react-ui/src/pages/Dataset/components/AddModelModal/index.tsx @@ -0,0 +1,115 @@ +import KFModal from '@/components/KFModal'; +import { CategoryData, DataSource } from '@/pages/Dataset/config'; +import { addModel } from '@/services/dataset/index.js'; +import { to } from '@/utils/promise'; +import { Form, Input, Radio, Select, message, type ModalProps } from 'antd'; + +interface AddModelModalProps extends Omit { + typeList: CategoryData[]; + tagList: CategoryData[]; + onOk: () => void; +} + +function AddModelModal({ typeList, tagList, onOk, ...rest }: AddModelModalProps) { + // 上传请求 + const createModel = async (params: any) => { + const [res] = await to(addModel(params)); + if (res) { + message.success('创建成功'); + onOk?.(); + } + }; + + // 提交 + const onFinish = (formData: any) => { + const params = { + ...formData, + model_source: DataSource.Create, + }; + createModel(params); + }; + + return ( + +
+ + + + + + + + + + + + 私有 + 公开 + + + +
+ ); +} + +export default AddModelModal; diff --git a/react-ui/src/pages/Dataset/components/AddVersionModal/index.tsx b/react-ui/src/pages/Dataset/components/AddVersionModal/index.tsx new file mode 100644 index 00000000..cbce7040 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/AddVersionModal/index.tsx @@ -0,0 +1,217 @@ +import { getAccessToken } from '@/access'; +import KFIcon from '@/components/KFIcon'; +import KFModal from '@/components/KFModal'; +import { DataSource, ResourceType, resourceConfig } from '@/pages/Dataset/config'; +import { to } from '@/utils/promise'; +import { getFileListFromEvent, removeUploadedFile, validateUploadFiles } from '@/utils/ui'; +import { + Button, + Form, + Input, + Upload, + UploadFile, + message, + type ModalProps, + type UploadProps, +} from 'antd'; +import { omit } from 'lodash'; +import { useEffect, useState } from 'react'; +import styles from '../AddDatasetModal/index.less'; + +interface AddVersionModalProps extends Omit { + resourceType: ResourceType; + resourceId: number; + resoureName: string; + owner: string; + identifier: string; + is_public: boolean; + onOk: () => void; +} + +function AddVersionModal({ + resourceType, + resourceId, + resoureName, + owner, + identifier, + is_public, + onOk, + ...rest +}: AddVersionModalProps) { + const [uuid] = useState(Date.now()); + const config = resourceConfig[resourceType]; + const [form] = Form.useForm(); + + useEffect(() => { + const getNextVersion = async () => { + const request = config.getNextVersion; + const params = { + identifier, + owner, + }; + const [res] = await to(request(params)); + if (res && res.data) { + const nextVersion = res.data; + form.setFieldValue('version', nextVersion); + } + }; + getNextVersion(); + }, [identifier, owner, config, form]); + + // 上传组件参数 + const uploadProps: UploadProps = { + action: config.uploadAction, + headers: { + Authorization: getAccessToken() || '', + }, + defaultFileList: [], + beforeUpload: config.beforeUpload, + accept: config.uploadAccept, + onRemove: removeUploadedFile, + }; + + // 上传请求 + const createDatasetVersion = async (params: any) => { + const request = config.addVersion; + const [res] = await to(request(params)); + if (res) { + message.success('创建成功'); + onOk?.(); + } + }; + + // 提交 + const onFinish = (formData: any) => { + const fileList: UploadFile[] = formData['fileList'] ?? []; + if (validateUploadFiles(fileList)) { + const version_vos = fileList.map((item) => { + const data = item.response?.data?.[0] ?? {}; + return { + file_name: data.fileName, + file_size: data.fileSize, + url: data.url, + }; + }); + const params = { + id: resourceId, + identifier, + is_public, + owner, + [config.filePropKey]: version_vos, + ...omit(formData, 'fileList'), + [config.sourceParamKey]: DataSource.Create, + }; + createDatasetVersion(params); + } + }; + + const name = config.name; + + return ( + +
+ + + + { + if (value === 'master') { + return Promise.reject(`${name}版本不能为 master`); + } else if (value === 'origin') { + return Promise.reject(`${name}版本不能为 origin`); + } + return Promise.resolve(); + }, + }, + ]} + > + + + + + + + + + {resourceType === ResourceType.Dataset && ( +
只允许上传 .zip 和 .tgz 格式文件
+ )} +
+
+ +
+ ); +} + +export default AddVersionModal; diff --git a/react-ui/src/pages/Dataset/components/CategoryItem/index.less b/react-ui/src/pages/Dataset/components/CategoryItem/index.less new file mode 100644 index 00000000..ea535ed6 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/CategoryItem/index.less @@ -0,0 +1,32 @@ +.category-item { + display: flex; + flex-direction: column; + align-items: center; + width: 92px; + padding: 10px 8px 8px; + color: @text-color; + font-size: 12px; + border: 1px solid rgba(22, 100, 255, 0.05); + border-radius: 4px; + cursor: pointer; + + &__icon { + display: block; + } + &__active-icon { + display: none; + } + + &:hover, + &--active { + background: rgba(22, 100, 255, 0.03); + border: 1px solid @primary-color; + + .category-item__icon { + display: none; + } + .category-item__active-icon { + display: block; + } + } +} diff --git a/react-ui/src/pages/Dataset/components/CategoryItem/index.tsx b/react-ui/src/pages/Dataset/components/CategoryItem/index.tsx new file mode 100644 index 00000000..d308dd45 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/CategoryItem/index.tsx @@ -0,0 +1,43 @@ +import { Typography } from 'antd'; +import classNames from 'classnames'; +import { CategoryData, ResourceType, resourceConfig } from '../../config'; +import styles from './index.less'; + +type CategoryItemProps = { + resourceType: ResourceType; + item: CategoryData; + isSelected: boolean; + onClick: (item: CategoryData) => void; +}; + +function CategoryItem({ resourceType, item, isSelected, onClick }: CategoryItemProps) { + const config = resourceConfig[resourceType]; + return ( +
onClick(item)} + > + + + + {item.name} + +
+ ); +} + +export default CategoryItem; diff --git a/react-ui/src/pages/Dataset/components/CategoryList/index.less b/react-ui/src/pages/Dataset/components/CategoryList/index.less new file mode 100644 index 00000000..5f925607 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/CategoryList/index.less @@ -0,0 +1,22 @@ +.category-list { + width: 340px; + height: 100%; + margin-right: 10px; + padding: 15px 0; + background: white; + border-radius: 4px; + box-shadow: 0px 3px 6px rgba(146, 146, 146, 0.09); + + &__content { + height: calc(100% - 32px - 15px); + margin-top: 15px; + padding-left: 20px; + overflow-y: auto; + + &__title { + margin-bottom: 15px; + color: @text-color; + font-size: 14px; + } + } +} diff --git a/react-ui/src/pages/Dataset/components/CategoryList/index.tsx b/react-ui/src/pages/Dataset/components/CategoryList/index.tsx new file mode 100644 index 00000000..44922a29 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/CategoryList/index.tsx @@ -0,0 +1,65 @@ +import { Flex, Input } from 'antd'; +import { CategoryData, ResourceType, resourceConfig } from '../../config'; +import CategoryItem from '../CategoryItem'; +import styles from './index.less'; + +type CategoryProps = { + resourceType: ResourceType; // 资源类型,数据集还是模型 + typeList: CategoryData[]; + tagList: CategoryData[]; + activeType?: string; + activeTag?: string; + onTypeSelect: (value: CategoryData) => void; + onTagSelect: (value: CategoryData) => void; + onSearch: (value: string) => void; +}; + +function CategoryList({ + resourceType, + typeList, + tagList, + activeType, + activeTag, + onTypeSelect, + onTagSelect, + onSearch, +}: CategoryProps) { + const config = resourceConfig[resourceType]; + return ( +
+
+ +
+
+
{config.typeTitle}
+ + {typeList?.map((item) => ( + + ))} + +
+ {config.tagTitle} +
+ + {tagList?.map((item) => ( + + ))} + +
+
+ ); +} + +export default CategoryList; diff --git a/react-ui/src/pages/Dataset/components/EditVersionModal/index.tsx b/react-ui/src/pages/Dataset/components/EditVersionModal/index.tsx new file mode 100644 index 00000000..f17bf404 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/EditVersionModal/index.tsx @@ -0,0 +1,121 @@ +import KFModal from '@/components/KFModal'; +import { DataSource, ResourceData, ResourceType, resourceConfig } from '@/pages/Dataset/config'; +import { to } from '@/utils/promise'; +import { Form, Input, message, type ModalProps } from 'antd'; + +interface EditVersionModalProps extends Omit { + resourceType: ResourceType; + resourceVersion: ResourceData; + onOk: () => void; +} + +function EditVersionModal({ resourceType, resourceVersion, onOk, ...rest }: EditVersionModalProps) { + const config = resourceConfig[resourceType]; + const { name: resoureName, version, version_desc } = resourceVersion; + + // 修改请求 + const editDatasetVersion = async (params: any) => { + const request = config.editVersion; + const [res] = await to(request(params)); + if (res) { + message.success('编辑成功'); + onOk?.(); + } + }; + + // 提交 + const onFinish = (formData: any) => { + const params = { + ...resourceVersion, + ...formData, + [config.sourceParamKey]: DataSource.Create, + }; + editDatasetVersion(params); + }; + + const name = config.name; + + return ( + +
+ + + + { + if (value === 'master') { + return Promise.reject(`${name}版本不能为 master`); + } else if (value === 'origin') { + return Promise.reject(`${name}版本不能为 origin`); + } + return Promise.resolve(); + }, + }, + ]} + > + + + + + + +
+ ); +} + +export default EditVersionModal; diff --git a/react-ui/src/pages/Dataset/components/ResourceInfo/index.less b/react-ui/src/pages/Dataset/components/ResourceInfo/index.less new file mode 100644 index 00000000..9f85e8f0 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/ResourceInfo/index.less @@ -0,0 +1,101 @@ +.resource-info { + height: 100%; + + &__top { + width: 100%; + height: 125px; + margin-bottom: 10px; + padding: 20px 30px; + background-image: url(@/assets/img/dataset-intro-top.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + + &__name { + margin-right: 10px; + color: @text-color; + font-weight: 500; + font-size: 20px; + .singleLine(); + } + + &__tag { + flex: none; + padding: 4px 10px; + color: @primary-color; + font-size: 14px; + background: .addAlpha(@primary-color, 0.1) []; + border-radius: 4px; + } + + &__desc { + margin-bottom: 0 !important; + color: @text-color; + font-size: @font-size; + } + + &__praise { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 70px; + height: 28px; + margin-left: auto; + color: @text-color-tertiary; + font-size: 13px; + background: #ffffff; + border: 1px solid rgba(22, 100, 255, 0.11); + border-radius: 4px; + cursor: pointer; + + &:hover { + border-color: .addAlpha(@primary-color, 0.5) []; + } + + &--praised { + color: @primary-color; + } + } + + :global { + .ant-btn-dangerous { + background-color: .addAlpha(@error-color, 0.06) [] !important; + border-color: .addAlpha(@error-color, 0.11) [] !important; + } + } + } + + &__bottom { + position: relative; + height: calc(100% - 135px); + + &__legend { + position: absolute; + top: 20px; + right: 30px; + } + + :global { + .ant-tabs { + height: 100%; + .ant-tabs-nav-wrap { + padding-top: 8px; + padding-left: @content-padding; + background-color: white; + border-radius: 10px 10px 0 0; + } + .ant-tabs-content-holder { + height: 100%; + .ant-tabs-content { + height: 100%; + .ant-tabs-tabpane { + height: 100%; + overflow-y: auto; + } + } + } + } + } + } +} diff --git a/react-ui/src/pages/Dataset/components/ResourceInfo/index.tsx b/react-ui/src/pages/Dataset/components/ResourceInfo/index.tsx new file mode 100644 index 00000000..774fb30d --- /dev/null +++ b/react-ui/src/pages/Dataset/components/ResourceInfo/index.tsx @@ -0,0 +1,386 @@ +/* + * @Author: 赵伟 + * @Date: 2024-09-06 09:23:15 + * @Description: 数据集、模型详情 + */ + +import KFEmpty, { EmptyType } from '@/components/KFEmpty'; +import KFIcon from '@/components/KFIcon'; +import { + ResourceData, + ResourceType, + ResourceVersionData, + resourceConfig, +} from '@/pages/Dataset/config'; +import GraphLegend from '@/pages/Model/components/GraphLegend'; +import ModelEvolution from '@/pages/Model/components/ModelEvolution'; +import { praiseResourceReq, unpraiseResourceReq } from '@/services/dataset'; +import { VersionChangedMessage } from '@/utils/constant'; +import { openAntdModal } from '@/utils/modal'; +import { to } from '@/utils/promise'; +import { modalConfirm } from '@/utils/ui'; +import { useParams, useSearchParams } from '@umijs/max'; +import { App, Button, Flex, Select, Tabs, Typography } from 'antd'; +import classNames from 'classnames'; +import { useCallback, useEffect, useState } from 'react'; +import AddVersionModal from '../AddVersionModal'; +import EditVersionModal from '../EditVersionModal'; +import ResourceIntro from '../ResourceIntro'; +import ResourceVersion from '../ResourceVersion'; +import VersionCompareModal from '../VersionCompareModal'; +import VersionSelectorModal from '../VersionSelectorModal'; +import styles from './index.less'; + +// 这里值小写是因为值会写在 url 中 +export enum ResourceInfoTabKeys { + Introduction = 'introduction', // 简介 + Version = 'version', // 版本 + Evolution = 'evolution', // 演化 +} + +type ResourceInfoProps = { + resourceType: ResourceType; +}; + +const ResourceInfo = ({ resourceType }: ResourceInfoProps) => { + const [info, setInfo] = useState({} as ResourceData); + const locationParams = useParams(); + const [searchParams] = useSearchParams(); + const resourceId = Number(locationParams.id); + // 模型演化传入的 tab + const defaultTab = searchParams.get('tab') || ResourceInfoTabKeys.Introduction; + // 模型演化传入的版本 + const versionParam = searchParams.get('version'); + const name = searchParams.get('name') || ''; + const owner = searchParams.get('owner') || ''; + const identifier = searchParams.get('identifier') || ''; + const is_public = (searchParams.get('is_public') || '') === 'true'; + const [versionList, setVersionList] = useState([]); + const [version, setVersion] = useState(undefined); + const [activeTab, setActiveTab] = useState(defaultTab); + const config = resourceConfig[resourceType]; + const typeName = config.name; // 数据集/模型 + const { message } = App.useApp(); + + // 获取详情 + const getResourceDetail = useCallback( + async (version?: string) => { + const params = { + id: resourceId, + owner, + name, + identifier, + version, + is_public, + }; + const request = config.getInfo; + const [res] = await to(request(params)); + if (res && res.data) { + setInfo(res.data); + } + }, + [config, resourceId, owner, name, identifier, is_public], + ); + + // 获取版本列表 + const getVersionList = useCallback( + async (refresh: boolean) => { + const request = config.getVersions; + const [res] = await to( + request({ + owner, + identifier, + }), + ); + if (res && res.data && res.data.length > 0) { + setVersionList(res.data); + if ( + !refresh && + versionParam && + res.data.find((item: ResourceVersionData) => item.name === versionParam) + ) { + setVersion(versionParam); + } else { + setVersion(res.data[0].name); + } + } else { + setVersion(undefined); + getResourceDetail(undefined); + } + }, + [config, owner, identifier, versionParam, getResourceDetail], + ); + + useEffect(() => { + if (version) { + getResourceDetail(version); + } + }, [version, getResourceDetail]); + + useEffect(() => { + getVersionList(false); + }, [getVersionList]); + + // 新建版本 + const showAddVersionModal = () => { + const { close } = openAntdModal(AddVersionModal, { + resourceType: resourceType, + resourceId: resourceId, + resoureName: info.name, + owner: info.owner, + identifier: info.identifier, + is_public: is_public, + onOk: () => { + getVersionList(true); + close(); + window.postMessage({ type: VersionChangedMessage }); + }, + }); + }; + + // 版本编辑 + const showEditVersionModal = () => { + const { close } = openAntdModal(EditVersionModal, { + resourceType: resourceType, + resourceVersion: info, + onOk: () => { + getResourceDetail(); + close(); + }, + }); + }; + + // 选择版本 + const showVersionSelector = () => { + const { close } = openAntdModal(VersionSelectorModal, { + versions: versionList, + onOk: (version: string[]) => { + showVersionComparison(version); + close(); + }, + }); + }; + + // 版本对比 + const showVersionComparison = (versions: string[]) => { + openAntdModal(VersionCompareModal, { + versions: versions, + resourceType: resourceType, + repo_id: resourceId, + owner: info.owner, + identifier: info.identifier, + is_public: is_public, + }); + }; + + // 版本变化 + const handleVersionChange = (value: string) => { + setVersion(value); + }; + + // 删除版本 + const deleteVersion = async () => { + const request = config.deleteVersion; + const params = { + id: resourceId, + owner, + identifier, + relative_paths: info.relative_paths, + version, + }; + const [res] = await to(request(params)); + if (res) { + message.success('删除成功'); + getVersionList(true); + window.postMessage({ type: VersionChangedMessage }); + } + }; + + // 处理删除 + const handleDelete = () => { + modalConfirm({ + title: '删除后,该版本将不可恢复', + content: '是否确认删除?', + okText: '确认', + cancelText: '取消', + onOk: () => { + deleteVersion(); + }, + }); + }; + + // 处理点赞 + const handlePraise = async () => { + const request = info.praised === true ? unpraiseResourceReq : praiseResourceReq; + const [res] = await to(request(info.id)); + if (res) { + message.success('操作成功'); + setInfo({ + ...info, + praised: !info.praised, + praises_count: info.praised ? info.praises_count - 1 : info.praises_count + 1, + }); + } + }; + + // 处理发布 + const handlePublish = async () => { + const request = config.publish; + const params = { + id: resourceId, + owner, + name, + identifier, + }; + const [res] = await to(request(params)); + if (res) { + message.success('操作成功'); + } + }; + + const items = [ + { + key: ResourceInfoTabKeys.Introduction, + label: `${typeName}简介`, + icon: , + children: ( + + ), + }, + { + key: ResourceInfoTabKeys.Version, + label: `${typeName}文件`, + icon: , + children: , + }, + ]; + + if (resourceType === ResourceType.Model) { + items.push({ + key: ResourceInfoTabKeys.Evolution, + label: `模型演化`, + icon: , + children: ( + + ), + }); + } + + const typePropertyName = config.typeParamKey as keyof ResourceData; + const tagPropertyName = config.tagParamKey as keyof ResourceData; + + return ( +
+
+ +
{info.name}
+ {info[typePropertyName] && ( +
+ {(info[typePropertyName] as string) || '--'} +
+ )} + {info[tagPropertyName] && ( +
+ {(info[tagPropertyName] as string) || '--'} +
+ )} + +
+ + {info.praises_count} +
+ +
+ {version ? ( + + 版本号: +
+ + ); +} + +export default ResourceVersion; diff --git a/react-ui/src/pages/Dataset/components/VersionCompareModal/index.less b/react-ui/src/pages/Dataset/components/VersionCompareModal/index.less new file mode 100644 index 00000000..4a1c0d94 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/VersionCompareModal/index.less @@ -0,0 +1,119 @@ +@purple-color: #6516ff; + +.title(@color, @background) { + width: 100%; + margin-bottom: 20px; + padding: 0 15px; + color: @color; + font-weight: 500; + font-size: @font-size; + line-height: 42px; + text-align: center; + background: @background; + border-radius: 4px 4px 0 0; +} + +.text() { + margin-bottom: 20px !important; + color: @text-color-secondary; + font-size: 13px; + line-height: 22px; + word-break: break-all; +} + +.version-container(@background) { + flex: 1; + min-width: 0; + background: @background; + border-radius: 4px; +} + +.version-compare { + :global { + .ant-modal-content { + padding: 40px 40px 25px !important; + } + .ant-modal-header { + margin-bottom: 20px !important; + } + .kf-modal-title { + color: @text-color; + font-weight: 500; + font-size: 20px; + } + } + + &__container { + display: flex; + flex-wrap: nowrap; + gap: 0 5px; + align-items: stretch; + height: 100%; + } + + &__fields { + flex: none; + width: 117px; + padding: 0 15px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid .addAlpha(@primary-color, 0.2) []; + border-radius: 4px; + box-shadow: 0px 3px 6px .addAlpha(@primary-color, 0.1) [] inset; + + &__title { + margin-bottom: 20px; + color: @text-color; + font-size: @font-size; + line-height: 42px; + } + &__text { + .text(); + + &--different { + color: @error-color; + } + } + } + + &__left { + .version-container(.addAlpha(@primary-color, 0.04) []); + + &__title { + .title(@primary-color, linear-gradient( + 159.9deg,rgba(138, 177, 255, 0.5) 0%, + rgba(22, 100, 255, 0.5) 100% + )); + } + + &__text { + padding: 0 15px; + text-align: center; + .text(); + + &--different { + color: @primary-color; + } + } + } + + &__right { + .version-container(rgba(100, 30, 237, 0.04)); + &__title { + .title(@purple-color, linear-gradient( + 159.9deg, + rgba(193, 138, 255, 0.5) 0%, + rgba(146, 22, 255, 0.5) 100% + )); + } + + &__text { + padding: 0 15px; + text-align: center; + .text(); + + &--different { + color: @purple-color; + } + } + } +} diff --git a/react-ui/src/pages/Dataset/components/VersionCompareModal/index.tsx b/react-ui/src/pages/Dataset/components/VersionCompareModal/index.tsx new file mode 100644 index 00000000..a5b37131 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/VersionCompareModal/index.tsx @@ -0,0 +1,250 @@ +import KFModal from '@/components/KFModal'; +import { + DatasetData, + ModelData, + ProjectDependency, + ResourceType, + TrainTask, + resourceConfig, +} from '@/pages/Dataset/config'; +import { isEmpty } from '@/utils'; +import { formatSource } from '@/utils/format'; +import { to } from '@/utils/promise'; +import { Typography, type ModalProps } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; +import styles from './index.less'; + +type CompareData = { + differences: Record; + version1: DatasetData | ModelData; + version2: DatasetData | ModelData; +}; + +type FiledType = { + key: keyof T; + text: string; + format?: (data: any) => any; +}; + +interface VersionCompareModalProps extends Omit { + versions: string[]; + resourceType: ResourceType; + identifier: string; + is_public: boolean; + owner: string; + repo_id: number; +} + +const formatDatasets = (datasets?: DatasetData[]) => { + if (!datasets || datasets.length === 0) { + return undefined; + } + return datasets.map((item) => `${item.name}:${item.version}`).join(','); +}; + +const formatProject = (project?: ProjectDependency) => { + if (!project) { + return undefined; + } + return `${project.name}:${project.branch}`; +}; + +const formatTrainTask = (task?: TrainTask) => { + if (!task) { + return undefined; + } + return `${task.name}:${task.ins_id}`; +}; + +function VersionCompareModal({ + versions, + resourceType, + identifier, + is_public, + owner, + repo_id, + ...rest +}: VersionCompareModalProps) { + const [compareData, setCompareData] = useState(undefined); + const config = resourceConfig[resourceType]; + const fields: FiledType[] | FiledType[] = useMemo( + () => + resourceType === ResourceType.Dataset + ? [ + { + key: 'dataset_source', + text: '数据来源', + format: formatSource, + }, + { + key: 'train_task', + text: '训练任务', + format: formatTrainTask, + }, + { + key: 'processing_code', + text: '处理代码', + format: formatProject, + }, + { + key: 'version_desc', + text: '版本描述', + }, + ] + : [ + { + key: 'image', + text: '训练镜像', + }, + { + key: 'project_depency', + text: '训练代码', + format: formatProject, + }, + { + key: 'train_datasets', + text: '训练数据集', + format: formatDatasets, + }, + { + key: 'test_datasets', + text: '测试数据集', + format: formatDatasets, + }, + { + key: 'model_source', + text: '模型来源', + format: formatSource, + }, + { + key: 'train_task', + text: '训练任务', + format: formatTrainTask, + }, + { + key: 'version_desc', + text: '版本描述', + }, + ], + [resourceType], + ); + + useEffect(() => { + // 获取对比数据 + const getServiceVersionCompare = async () => { + const params = { + versions, + identifier, + is_public, + owner, + repo_id, + }; + const request = config.compareVersion; + const [res] = await to(request(params)); + if (res && res.data) { + setCompareData(res.data); + } + }; + + getServiceVersionCompare(); + }, [versions, identifier, is_public, owner, repo_id, config]); + + // 获取值 + function getValue( + data: T, + key: keyof T, + format?: (data: any) => any, + ) { + const value = data[key]; + return format ? format(value) : value; + } + + const { + version1: v1 = {} as DatasetData | ModelData, + version2: v2 = {} as DatasetData | ModelData, + differences = {}, + } = compareData || {}; + + const isDifferent = (key: string) => { + return Object.keys(differences).includes(key); + }; + + return ( + +
+
+
基础版本号
+ {fields.map(({ key, text }) => ( +
+ {text} +
+ ))} +
+
+
+ + {v1.version} + +
+ {fields.map(({ key, format }) => { + const text = getValue(v1, key as keyof typeof v1, format); + return ( +
+ + {isEmpty(text) ? '--' : text} + +
+ ); + })} +
+
+
+ + {v2.version} + +
+ {fields.map(({ key, format }) => { + const text = getValue(v2, key as keyof typeof v2, format); + return ( +
+ + {isEmpty(text) ? '--' : text} + +
+ ); + })} +
+
+
+ ); +} + +export default VersionCompareModal; diff --git a/react-ui/src/pages/Dataset/components/VersionSelectorModal/index.less b/react-ui/src/pages/Dataset/components/VersionSelectorModal/index.less new file mode 100644 index 00000000..a7cfc361 --- /dev/null +++ b/react-ui/src/pages/Dataset/components/VersionSelectorModal/index.less @@ -0,0 +1,59 @@ +.version-selector-modal { + :global { + .ant-modal-content { + padding: 40px !important; + } + .kf-modal-title { + color: @text-color; + font-weight: 500; + font-size: 20px; + } + } +} + +.version-selector { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 20px; + width: 100%; + padding: 30px 13px 20px; + + :global { + .ant-checkbox-group { + display: flex; + flex-direction: column; + gap: 10px 0; + } + } + + &__item { + display: flex; + flex-direction: column; + align-items: center; + width: 103px; + padding: 15px; + color: @text-color-secondary; + font-size: 15px; + border: 1px solid rgba(136, 149, 168, 0.16); + border-radius: 6px; + + img { + width: 26px; + height: 26px; + margin-bottom: 4px; + } + + &:hover { + color: @text-color-secondary; + background-color: .addAlpha(@primary-color, 0.08) []; + border: 1px solid transparent; + } + + &--active { + color: @primary-color !important; + background-color: .addAlpha(@primary-color, 0.08) [] !important; + border: 1px solid @primary-color !important; + } + } +} diff --git a/react-ui/src/pages/Dataset/components/VersionSelectorModal/index.tsx b/react-ui/src/pages/Dataset/components/VersionSelectorModal/index.tsx new file mode 100644 index 00000000..b63d02fc --- /dev/null +++ b/react-ui/src/pages/Dataset/components/VersionSelectorModal/index.tsx @@ -0,0 +1,61 @@ +import KFModal from '@/components/KFModal'; +import { ResourceVersionData } from '@/pages/Dataset/config'; +import { Typography, message, type ModalProps } from 'antd'; +import classNames from 'classnames'; +import { useState } from 'react'; +import styles from './index.less'; + +interface VersionSelectorModalProps extends Omit { + versions: ResourceVersionData[]; + onOk: (versions: string[]) => void; +} + +function VersionSelectorModal({ versions, onOk, ...rest }: VersionSelectorModalProps) { + const [selVersions, setSelVersions] = useState([]); + + const handleOk = () => { + if (selVersions.length !== 2) { + message.error('请选择两个版本进行对比'); + return; + } + onOk(selVersions); + }; + + const handleClick = (version: string) => { + setSelVersions((prev) => { + if (prev.includes(version)) { + return prev.filter((item) => item !== version); + } + return [...prev, version]; + }); + }; + + return ( + +
+ {versions.map((item) => { + return ( +
handleClick(item.name)} + > + + {item.name} +
+ ); + })} +
+
+ ); +} + +export default VersionSelectorModal; diff --git a/react-ui/src/pages/Dataset/config.tsx b/react-ui/src/pages/Dataset/config.tsx new file mode 100644 index 00000000..04e9ee7b --- /dev/null +++ b/react-ui/src/pages/Dataset/config.tsx @@ -0,0 +1,240 @@ +import KFIcon from '@/components/KFIcon'; +import { CommonTabKeys } from '@/enums'; +import { + addDatasetVersion, + addModelVersion, + compareDatasetVersion, + compareModelVersion, + deleteDataset, + deleteDatasetVersion, + deleteModel, + deleteModelVersion, + editDatasetVersion, + editModelVersion, + getDatasetInfo, + getDatasetList, + getDatasetNextVersionReq, + getDatasetVersionList, + getModelInfo, + getModelList, + getModelNextVersionReq, + getModelVersionList, + publishDatasetReq, + publishModelReq, +} from '@/services/dataset/index.js'; +import { limitUploadFileType } from '@/utils/ui'; +import type { TabsProps, UploadFile } from 'antd'; + +export enum ResourceType { + Model = 'Model', // 模型 + Dataset = 'Dataset', // 数据集 +} + +export enum DataSource { + AutoExport = 'auto_export', // 自动导出 + HandExport = 'hand_export', // 手动导出 + Create = 'add', // 新增 + LabelStudioExport = 'label_studio_export', // LabelStudio 导出 +} + +type ResourceTypeInfo = { + getList: (params: any) => Promise; // 获取资源列表 + getVersions: (params: any) => Promise; // 获取版本列表 + deleteRecord: (params: any) => Promise; // 删除 + addVersion: (params: any) => Promise; // 新增版本 + editVersion: (params: any) => Promise; // 编辑版本 + deleteVersion: (params: any) => Promise; // 删除版本 + getInfo: (params: any) => Promise; // 获取详情 + compareVersion: (params: any) => Promise; // 版本对比 + getNextVersion: (params: any) => Promise; // 获取下一个版本 + publish: (params: any) => Promise; // 发布 + name: string; // 名称 + typeParamKey: 'data_type' | 'model_type'; // 类型参数名称,获取资源列表接口使用 + tagParamKey: 'data_tag' | 'model_tag'; // 标签参数名称,获取资源列表接口使用 + filePropKey: 'dataset_version_vos' | 'model_version_vos'; // 文件列表属性 + sourceParamKey: 'dataset_source' | 'model_source'; // 来源参数名称 + tabItems: TabsProps['items']; // tab 列表 + typeTitle: string; // 类型标题 + tagTitle: string; // 标签标题 + typeValue: number; // 从 getAssetIcon 接口获取特定值的数据为 type 分类 (category_id === typeValue) + tagValue: number; // 从 getAssetIcon 接口获取特定值的数据为 tag 分类(category_id === tagValue) + prefix: string; // 图片资源、详情 url 的前缀 + deleteModalTitle: string; // 删除弹框的title + addBtnTitle: string; // 新增按钮的title + uploadAction: string; // 上传接口 url + uploadAccept?: string; // 上传文件类型 + beforeUpload?: (file: UploadFile) => boolean | string; + downloadAllAction: string; // 批量下载接口 url + downloadSingleAction: string; // 单个下载接口 url +}; + +export const resourceConfig: Record = { + [ResourceType.Dataset]: { + getList: getDatasetList, + getVersions: getDatasetVersionList, + deleteRecord: deleteDataset, + addVersion: addDatasetVersion, + editVersion: editDatasetVersion, + deleteVersion: deleteDatasetVersion, + getInfo: getDatasetInfo, + compareVersion: compareDatasetVersion, + getNextVersion: getDatasetNextVersionReq, + publish: publishDatasetReq, + name: '数据集', + typeParamKey: 'data_type', + tagParamKey: 'data_tag', + filePropKey: 'dataset_version_vos', + sourceParamKey: 'dataset_source', + tabItems: [ + { + key: CommonTabKeys.Public, + label: '数据广场', + icon: , + }, + { + key: CommonTabKeys.Private, + label: '个人数据', + icon: , + }, + ], + typeTitle: '分类', + tagTitle: '研究方向/应用领域', + typeValue: 1, + tagValue: 2, + prefix: 'dataset', + deleteModalTitle: '确定删除该条数据集实例吗?', + addBtnTitle: '新建数据集', + uploadAction: '/api/mmp/newdataset/upload', + uploadAccept: '.zip,.tgz', + beforeUpload: limitUploadFileType('zip,tgz'), + downloadAllAction: '/api/mmp/newdataset/downloadAllFiles', + downloadSingleAction: '/api/mmp/newdataset/downloadSingleFile', + }, + [ResourceType.Model]: { + getList: getModelList, + getVersions: getModelVersionList, + deleteRecord: deleteModel, + addVersion: addModelVersion, + editVersion: editModelVersion, + deleteVersion: deleteModelVersion, + getInfo: getModelInfo, + compareVersion: compareModelVersion, + getNextVersion: getModelNextVersionReq, + publish: publishModelReq, + name: '模型', + typeParamKey: 'model_type', + tagParamKey: 'model_tag', + filePropKey: 'model_version_vos', + sourceParamKey: 'model_source', + tabItems: [ + { + key: CommonTabKeys.Public, + label: '模型广场', + icon: , + }, + { + key: CommonTabKeys.Private, + label: '个人模型', + icon: , + }, + ], + typeTitle: '模型框架', + tagTitle: '模型能力', + typeValue: 3, + tagValue: 4, + prefix: 'model', + deleteModalTitle: '确定删除该条模型实例吗?', + addBtnTitle: '新建模型', + uploadAction: '/api/mmp/newmodel/upload', + uploadAccept: undefined, + downloadAllAction: '/api/mmp/newmodel/downloadAllFiles', + downloadSingleAction: '/api/mmp/newmodel/downloadSingleFile', + }, +}; + +// 分类数据 +export type CategoryData = { + id: number; + category_id: number; + name: string; + path: string; +}; + +// 数据集、模型列表数据 +export interface ResourceData { + resourceType: ResourceType.Dataset | ResourceType.Model; // 用于 ts 类型判断 + id: number; + name: string; + identifier: string; + owner: string; + version: string; + is_public: boolean; + description?: string; + create_by?: string; + update_time?: string; + time_ago?: string; + version_desc?: string; + usage?: string; + relative_paths?: string; + train_task?: TrainTask; // 训练任务 + praises_count: number; // 点赞数 + praised: boolean; // 是否点赞 + full_last_update_time: string; // 完整的更新时间 +} + +// 数据集数据 +export interface DatasetData extends ResourceData { + resourceType: ResourceType.Dataset; // 用于区别类型 + data_type?: string; // 数据集分类 + data_tag?: string; // 研究方向 + processing_code?: ProjectDependency; // 处理代码 + dataset_source?: string; // 数据来源 + dataset_version_vos?: ResourceFileData[]; +} + +// 模型数据 +export interface ModelData extends ResourceData { + resourceType: ResourceType.Model; // 用于区别类型 + model_type?: string; // 模型框架 + model_tag?: string; // 模型能力 + image?: string; // 训练镜像 + code?: string; // 训练镜像 + train_datasets?: DatasetData[]; // 训练数据集 + test_datasets?: DatasetData[]; // 测试数据集 + params?: Record; // 参数 + metrics?: Record; // 指标 + project_depency?: ProjectDependency; // 项目依赖 + model_source?: string; // 模型来源 + model_version_vos?: ResourceFileData[]; +} + +// 版本数据 +export type ResourceVersionData = { + name: string; + http_url: string; + tar_url: string; + zip_url: string; +}; + +// 版本文件数据 +export type ResourceFileData = { + file_name: string; + file_size: string; + url: string; + update_time?: string; +}; + +// 训练任务 +export type TrainTask = { + ins_id: number; // 实例id + name: string; // 实验名称 + experiment_id: number; //实验 id + workflow_id: number; // 流水线 id +}; + +// 项目依赖 +export type ProjectDependency = { + url: string; // 项目地址 + name: string; // 项目名称 + branch: string; // 分支 +}; diff --git a/react-ui/src/pages/Dataset/index.tsx b/react-ui/src/pages/Dataset/index.tsx new file mode 100644 index 00000000..29963749 --- /dev/null +++ b/react-ui/src/pages/Dataset/index.tsx @@ -0,0 +1,7 @@ +import ResourcePage from './components/ResourcePage'; +import { ResourceType } from './config'; + +const DatasetPage = () => { + return ; +}; +export default DatasetPage; diff --git a/react-ui/src/pages/Dataset/intro.tsx b/react-ui/src/pages/Dataset/intro.tsx new file mode 100644 index 00000000..e40f8288 --- /dev/null +++ b/react-ui/src/pages/Dataset/intro.tsx @@ -0,0 +1,8 @@ +import ResourceInfo from '@/pages/Dataset/components/ResourceInfo'; +import { ResourceType } from '@/pages/Dataset/config'; + +function DatasetInfo() { + return ; +} + +export default DatasetInfo; diff --git a/react-ui/src/pages/DatasetPreparation/DatasetAnnotation/index.tsx b/react-ui/src/pages/DatasetPreparation/DatasetAnnotation/index.tsx new file mode 100644 index 00000000..f5badc92 --- /dev/null +++ b/react-ui/src/pages/DatasetPreparation/DatasetAnnotation/index.tsx @@ -0,0 +1,7 @@ +import IframePage, { IframePageType } from '@/components/IFramePage'; + +function DatasetAnnotation() { + return ; +} + +export default DatasetAnnotation; diff --git a/react-ui/src/pages/DevelopmentEnvironment/Create/index.less b/react-ui/src/pages/DevelopmentEnvironment/Create/index.less new file mode 100644 index 00000000..cd1dcb27 --- /dev/null +++ b/react-ui/src/pages/DevelopmentEnvironment/Create/index.less @@ -0,0 +1,17 @@ +.editor-create { + height: 100%; + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 30px 30px 10px; + overflow: auto; + background-color: white; + border-radius: 10px; + + &__type { + color: @text-color; + font-size: @font-size-input-lg; + } + } +} diff --git a/react-ui/src/pages/DevelopmentEnvironment/Create/index.tsx b/react-ui/src/pages/DevelopmentEnvironment/Create/index.tsx new file mode 100644 index 00000000..2d1683b8 --- /dev/null +++ b/react-ui/src/pages/DevelopmentEnvironment/Create/index.tsx @@ -0,0 +1,219 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 创建开发环境 + */ +import CodeSelect from '@/components/CodeSelect'; +import KFIcon from '@/components/KFIcon'; +import KFRadio, { type KFRadioItem } from '@/components/KFRadio'; +import PageTitle from '@/components/PageTitle'; +import ParameterSelect from '@/components/ParameterSelect'; +import ResourceSelect, { + requiredValidator, + ResourceSelectorType, + type ParameterInputObject, +} from '@/components/ResourceSelect'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { createEditorReq } from '@/services/developmentEnvironment'; +import { to } from '@/utils/promise'; +import { useNavigate } from '@umijs/max'; +import { App, Button, Col, Form, Input, Row } from 'antd'; +import styles from './index.less'; + +type FormData = { + name: string; + computing_resource: string; + standard: string; + image: ParameterInputObject; + model: ParameterInputObject; + dataset: ParameterInputObject; +}; + +enum ComputingResourceType { + GPU = 'GPU', + NPU = 'NPU', +} + +const EditorRadioItems: KFRadioItem[] = [ + { + title: '英伟达GPU', + value: ComputingResourceType.GPU, + icon: , + }, + { + title: '昇腾NPU', + value: ComputingResourceType.NPU, + icon: , + }, +]; + +function EditorCreate() { + const navigate = useNavigate(); + const [form] = Form.useForm(); + const { message } = App.useApp(); + + // 创建编辑器 + const createEditor = async (formData: FormData) => { + const [res] = await to(createEditorReq(formData)); + if (res) { + message.success('创建成功'); + navigate(-1); + } + }; + + // 提交 + const handleSubmit = (values: FormData) => { + createEditor(values); + }; + + // 取消 + const cancel = () => { + navigate(-1); + }; + + return ( +
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default EditorCreate; diff --git a/react-ui/src/pages/DevelopmentEnvironment/Editor/index.tsx b/react-ui/src/pages/DevelopmentEnvironment/Editor/index.tsx new file mode 100644 index 00000000..ad9afbee --- /dev/null +++ b/react-ui/src/pages/DevelopmentEnvironment/Editor/index.tsx @@ -0,0 +1,12 @@ +/* + * @Author: 赵伟 + * @Date: 2024-06-24 16:38:59 + * @Description: 开发环境 + */ + +import IframePage, { IframePageType } from '@/components/IFramePage'; + +function DevEditor() { + return ; +} +export default DevEditor; diff --git a/react-ui/src/pages/DevelopmentEnvironment/List/index.less b/react-ui/src/pages/DevelopmentEnvironment/List/index.less new file mode 100644 index 00000000..d29e8a2d --- /dev/null +++ b/react-ui/src/pages/DevelopmentEnvironment/List/index.less @@ -0,0 +1,22 @@ +.develop-env { + height: 100%; + &__header { + display: flex; + align-items: center; + justify-content: flex-end; + height: 50px; + margin-bottom: 10px; + padding: 0 30px; + background-image: url(@/assets/img/page-title-bg.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + } + + &__table { + height: calc(100% - 60px); + padding: 20px 30px 0; + background-color: white; + border-radius: 10px; + } +} diff --git a/react-ui/src/pages/DevelopmentEnvironment/List/index.tsx b/react-ui/src/pages/DevelopmentEnvironment/List/index.tsx new file mode 100644 index 00000000..5395d44e --- /dev/null +++ b/react-ui/src/pages/DevelopmentEnvironment/List/index.tsx @@ -0,0 +1,415 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 开发环境列表 + */ + +import { CodeConfigData } from '@/components/CodeSelectorModal'; +import KFIcon from '@/components/KFIcon'; +import { DevEditorStatus } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { useSystemResource } from '@/hooks/useComputingResource'; +import { DatasetData, ModelData } from '@/pages/Dataset/config'; +import { + deleteEditorReq, + getEditorListReq, + startEditorReq, + stopEditorReq, +} from '@/services/developmentEnvironment'; +import themes from '@/styles/theme.less'; +import { parseJsonText } from '@/utils'; +import { formatCodeConfig, formatDataset, formatModel } from '@/utils/format'; +import { openAntdModal } from '@/utils/modal'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { modalConfirm } from '@/utils/ui'; +import { useNavigate } from '@umijs/max'; +import { + App, + Button, + ConfigProvider, + Table, + type TablePaginationConfig, + type TableProps, +} from 'antd'; +import classNames from 'classnames'; +import { useCallback, useEffect, useState } from 'react'; +import CreateMirrorModal from '../components/CreateMirrorModal'; +import EditorStatusCell from '../components/EditorStatusCell'; +import styles from './index.less'; + +export type EditorData = { + id: number; + name: string; + status: string; + computing_resource: string; + update_by: string; + create_time: string; + url: string; + computing_resource_id: number; + dataset?: string | DatasetData; + model?: string | ModelData; + image?: string; + code_config?: string | CodeConfigData; +}; + +function EditorList() { + const navigate = useNavigate(); + const [cacheState, setCacheState] = useCacheState(); + const { message } = App.useApp(); + const [tableData, setTableData] = useState([]); + const [total, setTotal] = useState(0); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 10, + }, + ); + const getResourceDescription = useSystemResource(); + + // 获取编辑器列表 + const getEditorList = useCallback(async () => { + const params: Record = { + page: pagination.current! - 1, + size: pagination.pageSize, + }; + const [res] = await to(getEditorListReq(params)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + content.forEach((item: EditorData) => { + item.dataset = typeof item.dataset === 'string' ? parseJsonText(item.dataset) : null; + item.model = typeof item.model === 'string' ? parseJsonText(item.model) : null; + item.image = typeof item.image === 'string' ? parseJsonText(item.image) : null; + item.code_config = + typeof item.code_config === 'string' ? parseJsonText(item.code_config) : null; + }); + setTableData(content); + setTotal(totalElements); + } + }, [pagination]); + + useEffect(() => { + getEditorList(); + }, [getEditorList]); + + // 删除编辑器 + const deleteEditor = async (id: number) => { + const [res] = await to(deleteEditorReq(id)); + if (res) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: tableData.length === 1 ? Math.max(1, prev.current! - 1) : prev.current, + }; + }); + } + }; + + // 启动编辑器 + const startEditor = async (id: number) => { + const [res] = await to(startEditorReq(id)); + if (res) { + message.success('操作成功'); + getEditorList(); + } + }; + + // 停止编辑器 + const stopEditor = async (id: number) => { + modalConfirm({ + title: '停止后,该编辑器将不可使用', + content: '是否确认停止?', + isDelete: false, + onOk: async () => { + const [res] = await to(stopEditorReq(id)); + if (res) { + message.success('操作成功'); + getEditorList(); + } + }, + }); + }; + + // 制作镜像 + const createMirror = (id: number) => { + const { close } = openAntdModal(CreateMirrorModal, { + envId: id, + onOk: () => { + close(); + }, + }); + }; + + // 处理删除 + const handleEditorDelete = (record: EditorData) => { + modalConfirm({ + title: '删除后,该编辑器将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteEditor(record.id); + }, + }); + }; + + // 创建编辑器 + const createEditor = () => { + navigate(`/developmentEnvironment/create`); + setCacheState({ + pagination, + }); + }; + + // 跳转编辑器页面 + const gotoEditorPage = (record: EditorData, e: React.MouseEvent) => { + e.stopPropagation(); + + setCacheState({ + pagination, + }); + + SessionStorage.setItem(SessionStorage.editorUrlKey, record.url); + navigate(`/developmentEnvironment/editor`); + }; + + // 去数据集 + const gotoDataset = (record: EditorData, e: React.MouseEvent) => { + e.stopPropagation(); + + const dataset = record.dataset as DatasetData; + const link = formatDataset(dataset)?.link; + if (link) { + setCacheState({ + pagination, + }); + navigate(link); + } + }; + + // 去模型 + const gotoModel = (record: EditorData, e: React.MouseEvent) => { + e.stopPropagation(); + + const model = record.model as ModelData; + const link = formatModel(model)?.link; + if (link) { + setCacheState({ + pagination, + }); + navigate(link); + } + }; + + // 打开代码配置仓库 + const gotoCodeConfig = (record: EditorData, e: React.MouseEvent) => { + e.stopPropagation(); + + const codeConfig = record.code_config as CodeConfigData; + const url = formatCodeConfig(codeConfig)?.url; + if (url) { + window.open(url, '_blank'); + } + }; + + // 分页切换 + const handleTableChange: TableProps['onChange'] = ( + pagination, + _filters, + _sorter, + { action }, + ) => { + if (action === 'paginate') { + setPagination(pagination); + } + }; + + const columns: TableProps['columns'] = [ + { + title: '编辑器名称', + dataIndex: 'name', + key: 'name', + width: '12%', + render: (text, record, index) => + record.url && record.status === DevEditorStatus.Running + ? tableCellRender(true, TableCellValueType.Link, { + onClick: gotoEditorPage, + })(text, record, index) + : tableCellRender(true, TableCellValueType.Text)(text, record, index), + }, + { + title: '计算资源', + dataIndex: 'computing_resource', + key: 'computing_resource', + width: '11%', + render: tableCellRender(), + }, + { + title: '资源规格', + dataIndex: 'computing_resource_id', + key: 'computing_resource_id', + width: '11%', + render: tableCellRender(true, TableCellValueType.Custom, { + format: getResourceDescription, + }), + }, + { + title: '数据集', + dataIndex: ['dataset', 'showValue'], + key: 'dataset', + width: '11%', + render: tableCellRender(true, TableCellValueType.Link, { + onClick: gotoDataset, + }), + }, + { + title: '模型', + dataIndex: ['model', 'showValue'], + key: 'model', + width: '11%', + render: tableCellRender(true, TableCellValueType.Link, { + onClick: gotoModel, + }), + }, + { + title: '代码配置', + dataIndex: ['code_config', 'showValue'], + key: 'code_config', + width: '11%', + render: tableCellRender(true, TableCellValueType.Link, { + onClick: gotoCodeConfig, + }), + }, + { + title: '镜像', + dataIndex: ['image', 'showValue'], + key: 'image', + width: '11%', + render: tableCellRender(true), + }, + { + title: '创建者', + dataIndex: 'update_by', + key: 'update_by', + width: '11%', + render: tableCellRender(true), + }, + { + title: '创建时间', + dataIndex: 'create_time', + key: 'create_time', + width: '11%', + render: tableCellRender(true, TableCellValueType.Date), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: EditorStatusCell, + }, + { + title: '操作', + dataIndex: 'operation', + width: 270, + key: 'operation', + render: (_: any, record: EditorData) => ( +
+ {record.status === DevEditorStatus.Pending || + record.status === DevEditorStatus.Running ? ( + + ) : ( + + )} + {record.status !== DevEditorStatus.Running ? ( + + ) : null} + + + +
+ ), + }, + ]; + + return ( +
+
+ + +
+
+
`共${total}条`, + }} + onChange={handleTableChange} + rowKey="id" + /> + + + ); +} + +export default EditorList; diff --git a/react-ui/src/pages/DevelopmentEnvironment/components/CreateMirrorModal/index.tsx b/react-ui/src/pages/DevelopmentEnvironment/components/CreateMirrorModal/index.tsx new file mode 100644 index 00000000..7155bc30 --- /dev/null +++ b/react-ui/src/pages/DevelopmentEnvironment/components/CreateMirrorModal/index.tsx @@ -0,0 +1,100 @@ +import KFModal from '@/components/KFModal'; +import { createEditorMirrorReq } from '@/services/developmentEnvironment'; +import { to } from '@/utils/promise'; +import { Form, Input, message, type ModalProps } from 'antd'; + +interface CreateMirrorModalProps extends Omit { + envId: number; // 开发环境id + onOk: () => void; +} + +function CreateMirrorModal({ envId, onOk, ...rest }: CreateMirrorModalProps) { + // 上传请求 + const createDatasetVersion = async (params: any) => { + const [res] = await to( + createEditorMirrorReq({ + ...params, + dev_environment_id: envId, + upload_type: 1, + version: params['tagName'], + }), + ); + if (res) { + message.success('创建成功,请到 “多形态资源库” - “个人镜像” 中查看'); + onOk?.(); + } + }; + + // 提交 + const onFinish = (formData: any) => { + createDatasetVersion(formData); + }; + + return ( + +
+ + + + + + + + + + +
+ ); +} + +export default CreateMirrorModal; diff --git a/react-ui/src/pages/DevelopmentEnvironment/components/EditorStatusCell/index.less b/react-ui/src/pages/DevelopmentEnvironment/components/EditorStatusCell/index.less new file mode 100644 index 00000000..b2e46d49 --- /dev/null +++ b/react-ui/src/pages/DevelopmentEnvironment/components/EditorStatusCell/index.less @@ -0,0 +1,19 @@ +.model-deployment-status-cell { + color: @text-color; + + &--running { + color: @primary-color; + } + + &--terminated { + color: @abort-color; + } + + &--error { + color: @error-color; + } + + &--pending { + color: @warning-color; + } +} diff --git a/react-ui/src/pages/DevelopmentEnvironment/components/EditorStatusCell/index.tsx b/react-ui/src/pages/DevelopmentEnvironment/components/EditorStatusCell/index.tsx new file mode 100644 index 00000000..90ad8c75 --- /dev/null +++ b/react-ui/src/pages/DevelopmentEnvironment/components/EditorStatusCell/index.tsx @@ -0,0 +1,44 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-18 18:35:41 + * @Description: 编辑器状态组件 + */ +import { DevEditorStatus } from '@/enums'; +import styles from './index.less'; + +export type DevEditorStatusInfo = { + text: string; + classname: string; +}; + +export const statusInfo: Record = { + [DevEditorStatus.Unknown]: { + text: '未启动', + classname: styles['model-deployment-status-cell'], + }, + [DevEditorStatus.Running]: { + classname: styles['model-deployment-status-cell--running'], + text: '运行中', + }, + [DevEditorStatus.Terminated]: { + classname: styles['model-deployment-status-cell--terminated'], + text: '已停止', + }, + [DevEditorStatus.Failed]: { + classname: styles['model-deployment-status-cell--error'], + text: '失败', + }, + [DevEditorStatus.Pending]: { + classname: styles['model-deployment-status-cell--pending'], + text: '启动中', + }, +}; + +function EditorStatusCell(status?: DevEditorStatus | null) { + if (status === null || status === undefined || !statusInfo[status]) { + return --; + } + return {statusInfo[status].text}; +} + +export default EditorStatusCell; diff --git a/react-ui/src/pages/Docs/index.less b/react-ui/src/pages/Docs/index.less new file mode 100644 index 00000000..e69de29b diff --git a/react-ui/src/pages/Docs/index.tsx b/react-ui/src/pages/Docs/index.tsx new file mode 100644 index 00000000..914726f9 --- /dev/null +++ b/react-ui/src/pages/Docs/index.tsx @@ -0,0 +1,9 @@ +const Docs = () => { + return ( + + ); +}; +export default Docs; diff --git a/react-ui/src/pages/Experiment/Aim/index.tsx b/react-ui/src/pages/Experiment/Aim/index.tsx new file mode 100644 index 00000000..3a8d1d0d --- /dev/null +++ b/react-ui/src/pages/Experiment/Aim/index.tsx @@ -0,0 +1,12 @@ +/* + * @Author: 赵伟 + * @Date: 2025-03-31 16:38:59 + * @Description: 实验对比 Aim + */ + +import IframePage, { IframePageType } from '@/components/IFramePage'; + +function AimPage() { + return ; +} +export default AimPage; diff --git a/react-ui/src/pages/Experiment/Comparison/config.tsx b/react-ui/src/pages/Experiment/Comparison/config.tsx new file mode 100644 index 00000000..c6c53971 --- /dev/null +++ b/react-ui/src/pages/Experiment/Comparison/config.tsx @@ -0,0 +1,17 @@ +export enum ComparisonType { + Train = 'Train', // 训练 + Evaluate = 'Evaluate', // 评估 +} + +type ComparisonTypeInfo = { + title: string; +}; + +export const comparisonConfig: Record = { + [ComparisonType.Train]: { + title: '训练', + }, + [ComparisonType.Evaluate]: { + title: '评估', + }, +}; diff --git a/react-ui/src/pages/Experiment/Comparison/index.less b/react-ui/src/pages/Experiment/Comparison/index.less new file mode 100644 index 00000000..e34f03ad --- /dev/null +++ b/react-ui/src/pages/Experiment/Comparison/index.less @@ -0,0 +1,46 @@ +.experiment-comparison { + height: 100%; + &__header { + display: flex; + align-items: center; + height: 50px; + margin-bottom: 10px; + padding: 0 30px; + background-image: url(@/assets/img/page-title-bg.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + } + + &__table { + height: calc(100% - 60px); + padding: 20px 30px 0; + background-color: white; + border-radius: 10px; + + :global { + .ant-table-container { + border: none !important; + } + .ant-table-thead { + .ant-table-cell { + background-color: rgb(247, 247, 247); + border-color: @border-color !important; + } + } + .ant-table-tbody { + .ant-table-cell { + border-right: none !important; + border-left: none !important; + } + } + .ant-table-footer { + padding: 0; + border: none !important; + } + .ant-table-column-title { + min-width: 0; + } + } + } +} diff --git a/react-ui/src/pages/Experiment/Comparison/index.tsx b/react-ui/src/pages/Experiment/Comparison/index.tsx new file mode 100644 index 00000000..d10e8069 --- /dev/null +++ b/react-ui/src/pages/Experiment/Comparison/index.tsx @@ -0,0 +1,224 @@ +/* + * @Author: 赵伟 + * @Date: 2024-10-10 09:55:12 + * @Description: 实验对比 + */ + +import TableColTitle from '@/components/TableColTitle'; +import { + getExpEvaluateInfosReq, + getExpMetricsReq, + getExpTrainInfosReq, +} from '@/services/experiment'; +import { tableSorter } from '@/utils'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { useNavigate, useSearchParams } from '@umijs/max'; +import { App, Button, Table, TablePaginationConfig, TableProps } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; +import ExperimentStatusCell from '../components/ExperimentStatusCell'; +import { ComparisonType, comparisonConfig } from './config'; +import styles from './index.less'; + +type TableData = { + experiment_ins_id: number; + run_id: string; + dataset: string[]; + start_time: string; + status: string; + metrics_names?: string[]; + metrics?: Record; + params_names?: string[]; + params?: Record; +}; + +function ExperimentComparison() { + const [searchParams] = useSearchParams(); + const comparisonType = searchParams.get('type') as ComparisonType; + const experimentId = searchParams.get('id'); + const [tableData, setTableData] = useState([]); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [total, setTotal] = useState(0); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + }); + + const { message } = App.useApp(); + const navigate = useNavigate(); + const config = comparisonConfig[comparisonType]; + + useEffect(() => { + // 获取对比数据列表 + const getComparisonData = async () => { + const request = + comparisonType === ComparisonType.Train ? getExpTrainInfosReq : getExpEvaluateInfosReq; + const params = { + page: pagination.current! - 1, + size: pagination.pageSize, + }; + const [res] = await to(request(experimentId, params)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + setTableData(content); + setTotal(totalElements); + } + }; + + getComparisonData(); + }, [experimentId, pagination, comparisonType]); + + // 获取对比 url + const getExpMetrics = async () => { + const [res] = await to(getExpMetricsReq(selectedRowKeys)); + if (res && res.data) { + const url = res.data; + // window.open(url, '_blank'); + SessionStorage.setItem(SessionStorage.aimUrlKey, url); + navigate('compare-visual'); + } + }; + + // 对比按钮 click + const handleComparisonClick = () => { + if (selectedRowKeys.length < 2) { + message.error('请至少选择两项进行对比'); + return; + } + getExpMetrics(); + }; + + // 选择行 + const rowSelection: TableProps['rowSelection'] = { + type: 'checkbox', + columnWidth: 48, + fixed: 'left', + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys); + }, + }; + + // 分页切换 + const handleTableChange: TableProps['onChange'] = ( + pagination, + _filters, + _sorter, + { action }, + ) => { + if (action === 'paginate') { + setPagination(pagination); + } + }; + + const columns: TableProps['columns'] = useMemo(() => { + const first: TableData | undefined = tableData.find( + (item) => item.metrics_names && item.metrics_names.length > 0, + ); + const metricsNames = first?.metrics_names ?? []; + const paramsNames = first?.params_names ?? []; + return [ + { + title: '基本信息', + align: 'center', + children: [ + { + title: '实例 ID', + dataIndex: 'experiment_ins_id', + key: 'experiment_ins_id', + width: 100, + fixed: 'left', + align: 'center', + render: tableCellRender(), + }, + { + title: '运行时间', + dataIndex: 'start_time', + key: 'start_time', + width: 180, + fixed: 'left', + align: 'center', + render: tableCellRender(false, TableCellValueType.Date), + }, + { + title: '运行状态', + dataIndex: 'status', + key: 'status', + width: 100, + fixed: 'left', + // align: 'center', + render: ExperimentStatusCell, + }, + { + title: `${config.title}数据集`, + dataIndex: 'dataset', + key: 'dataset', + width: 180, + fixed: 'left', + align: 'center', + render: tableCellRender(true, TableCellValueType.Array), + }, + ], + }, + { + title: `${config.title}参数`, + align: 'center', + children: paramsNames.map((name) => ({ + title: , + dataIndex: ['params', name], + key: name, + width: 150, + align: 'center', + render: tableCellRender(true), + sorter: (a, b) => tableSorter(a.params?.[name], b.params?.[name]), + showSorterTooltip: false, + })), + }, + { + title: `${config.title}指标`, + align: 'center', + children: metricsNames.map((name) => ({ + title: , + dataIndex: ['metrics', name], + key: name, + width: 150, + align: 'center', + render: tableCellRender(true), + sorter: (a, b) => tableSorter(a.metrics?.[name], b.metrics?.[name]), + showSorterTooltip: false, + })), + }, + ]; + }, [tableData, config]); + + return ( +
+
+ +
+
+
record.run_id || record.experiment_ins_id} + /> + + + ); +} + +export default ExperimentComparison; diff --git a/react-ui/src/pages/Experiment/Info/index.jsx b/react-ui/src/pages/Experiment/Info/index.jsx new file mode 100644 index 00000000..296dbfe3 --- /dev/null +++ b/react-ui/src/pages/Experiment/Info/index.jsx @@ -0,0 +1,581 @@ +import RunDuration from '@/components/RunDuration'; +import { ExperimentStatus } from '@/enums'; +import { useStateRef } from '@/hooks/useStateRef'; +import { useVisible } from '@/hooks/useVisible'; +import { getExperimentIns } from '@/services/experiment/index.js'; +import themes from '@/styles/theme.less'; +import { fittingString, parseJsonText } from '@/utils'; +import { formatDate } from '@/utils/date'; +import { getExperimentInstanceStatus } from '@/utils/experiment'; +import { to } from '@/utils/promise'; +import G6, { Util } from '@antv/g6'; +import { Button } from 'antd'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import ExperimentDrawer from '../components/ExperimentDrawer'; +import ParamsModal from '../components/ViewParamsModal'; +import { experimentStatusInfo } from '../status'; +import styles from './index.less'; + +let graph = null; +const NodePrefix = 'workflow'; + +function ExperimentText() { + const [experimentIns, setExperimentIns] = useState(undefined); + const [experimentNodeData, setExperimentNodeData, experimentNodeDataRef] = useStateRef(undefined); + const [workflowStatus, setWorkflowStatus] = useState(undefined); + const graphRef = useRef(); + const workflowRef = useRef(); + const locationParams = useParams(); // 新版本获取路由参数接口 + const [paramsModalOpen, openParamsModal, closeParamsModal] = useVisible(false); + const [propsDrawerOpen, openPropsDrawer, closePropsDrawer, propsDrawerOpenRef] = + useVisible(false); + const navigate = useNavigate(); + const evtSourceRef = useRef(); + const width = 110; + const height = 36; + const status = getExperimentInstanceStatus(experimentIns?.status, workflowStatus); + const statusInfo = experimentStatusInfo[status]; + + useEffect(() => { + initGraph(); + getExperimentInstance(); + + return () => { + if (evtSourceRef.current) { + evtSourceRef.current.close(); + evtSourceRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const changeSize = () => { + if (!graph || graph.get('destroyed')) return; + if (!graphRef.current) return; + graph.changeSize(graphRef.current.clientWidth, graphRef.current.clientHeight); + graph.fitView(); + }; + + window.addEventListener('resize', changeSize); + return () => { + window.removeEventListener('resize', changeSize); + }; + }, []); + + // 获取流水线模版 + // const getWorkflow = async () => { + // const [res] = await to(getWorkflowById(locationParams.workflowId)); + // if (res && res.data && res.data.dag) { + // try { + // const dag = JSON.parse(res.data.dag); + // dag.nodes.forEach((item) => { + // item.in_parameters = JSON.parse(item.in_parameters); + // item.out_parameters = JSON.parse(item.out_parameters); + // item.control_strategy = JSON.parse(item.control_strategy); + // item.imgName = item.img.slice(0, item.img.length - 4); + // }); + // workflowRef.current = dag; + // getExperimentInstance(); + // } catch (error) { + // // JSON.parse 错误 + // console.error('JSON.parse error: ', error); + // } + // } + // }; + + // 获取实验实例 + const getExperimentInstance = async () => { + const [res] = await to(getExperimentIns(locationParams.id)); + if (res && res.data) { + setExperimentIns(res.data); + const { status, nodes_status, argo_ins_ns, argo_ins_name, finish_time, dag } = res.data; + if (!dag) { + return; + } + + const workflow = dag; + const experimentStatusObjs = parseJsonText(nodes_status); + if (!workflow || !workflow.nodes) { + return; + } + + workflow.nodes.forEach((item) => { + item.imgName = item.img.slice(0, item.img.length - 4); + }); + workflowRef.current = workflow; + + if (experimentStatusObjs) { + // 更新各个节点 + workflow.nodes.forEach((item) => { + const experimentNode = experimentStatusObjs[item.id]; + updateWorkflowNode(item, experimentNode); + }); + + // 设置 workflow 总状态 + Object.keys(experimentStatusObjs).some((key) => { + if (key.startsWith(NodePrefix)) { + const tempWorkflowStatus = experimentStatusObjs[key]; + setWorkflowStatus(tempWorkflowStatus); + return true; + } + return false; + }); + } + + // 绘制图 + getGraphData(workflow, true); + + if (status === ExperimentStatus.Pending) { + // 如果状态是 Pending, 打开第一个节点 + const node = workflow.nodes[0]; + if (node) { + setExperimentNodeData(node); + openPropsDrawer(); + } + } else if (status === ExperimentStatus.Running) { + // 如果状态是 Running,打开第一个 Running 或者 pending 的节点,如果没有,则打开第一个节点 + const node = + workflow.nodes.find( + (item) => + item.experimentStatus === ExperimentStatus.Running || + item.experimentStatus === ExperimentStatus.Pending, + ) ?? workflow.nodes[0]; + if (node) { + setExperimentNodeData(node); + openPropsDrawer(); + } + } + + // 运行中或者等待中,开启 SSE + if (status === ExperimentStatus.Pending || status === ExperimentStatus.Running) { + setupSSE(argo_ins_name, argo_ins_ns); + } + } + }; + + const setupSSE = (name, namespace) => { + const { origin } = location; + const params = encodeURIComponent(`metadata.namespace=${namespace},metadata.name=${name}`); + const evtSource = new EventSource( + `${origin}/api/v1/realtimeStatus?listOptions.fieldSelector=${params}`, + { withCredentials: false }, + ); + evtSource.onmessage = (event) => { + const data = event?.data; + if (!data) { + return; + } + try { + const dataJson = parseJsonText(data); + const statusData = dataJson?.result?.object?.status; + if (!statusData) { + return; + } + const { finishedAt, phase, nodes = {} } = statusData; + + // 更新实验实例状态和结束时间 + // setExperimentIns((prev) => ({ + // ...prev, + // finish_time: finishedAt, + // status: phase, + // })); + + // 设置总 workflow 状态 + const tempWorkflowStatus = Object.values(nodes).find((node) => + node.displayName.startsWith(NodePrefix), + ); + if (tempWorkflowStatus) { + setWorkflowStatus(tempWorkflowStatus); + } + + // 更新各个节点 + const workflowData = workflowRef.current; + workflowData.nodes.forEach((item) => { + const experimentNode = Object.values(nodes).find((node) => node.displayName === item.id); + updateWorkflowNode(item, experimentNode); + }); + + // 绘制图 + getGraphData(workflowData, false); + + // 更新打开的抽屉数据 + if (propsDrawerOpenRef.current && experimentNodeDataRef.current) { + const currentId = experimentNodeDataRef.current.id; + const node = workflowData.nodes.find((item) => item.id === currentId); + if (node) { + setExperimentNodeData(node); + } + } + if (phase !== ExperimentStatus.Pending && phase !== ExperimentStatus.Running) { + evtSource.close(); + evtSourceRef.current = null; + } + } catch (error) { + console.error('JSON.parse error: ', error); + } + }; + evtSource.onerror = (error) => { + console.error('SSE error: ', error); + }; + + evtSourceRef.current = evtSource; + }; + + // 更新各个节点 + function updateWorkflowNode(workflowNode, statusNode) { + if (!statusNode) { + return; + } + const { finishedAt, startedAt, phase, id, message } = statusNode; + workflowNode.experimentStartTime = startedAt; + workflowNode.experimentEndTime = finishedAt; + workflowNode.experimentStatus = phase; + workflowNode.workflowId = id; + workflowNode.message = message; + workflowNode.img = phase + ? `${workflowNode.imgName}-${phase}.png` + : `${workflowNode.imgName}.png`; + } + + // 根据数据,渲染图 + const getGraphData = (data, first) => { + if (graph) { + const zoom = graph.getZoom(); + // 在拉取新数据重新渲染页面之前先获取点(0, 0)在画布上的位置 + const lastPoint = graph.getCanvasByPoint(0, 0); + graph.data(data); + graph.render(); + if (first) { + graph.fitView(); + } else { + graph.zoomTo(zoom); + // 获取重新渲染之后点(0, 0)在画布的位置 + const newPoint = graph.getCanvasByPoint(0, 0); + // 移动画布相对位移; + graph.translate(lastPoint.x - newPoint.x, lastPoint.y - newPoint.y); + } + } else { + setTimeout(() => { + getGraphData(data, first); + }, 500); + } + }; + + const initGraph = () => { + G6.registerNode( + 'rect-node', + { + // draw anchor-point circles according to the anchorPoints in afterDraw + getAnchorPoints(cfg) { + return ( + cfg.anchorPoints || [ + // 四个,上下左右 + [0.5, 0], + [0.5, 1], + [0, 0.5], + [1, 0.5], + ] + ); + }, + afterDraw(cfg, group) { + const image = group.addShape('image', { + attrs: { + x: -45, + y: -10, + width: 20, + height: 20, + img: cfg.img, + cursor: 'pointer', + }, + draggable: true, + }); + if (cfg.label) { + group.addShape('text', { + attrs: { + text: fittingString(cfg.label, 70, 10), + x: -20, + y: 0, + fontSize: 10, + textAlign: 'left', + textBaseline: 'middle', + fill: '#000', + cursor: 'pointer', + }, + name: 'text-shape', + draggable: true, + }); + } + const hasRightImg = + cfg.experimentStatus === ExperimentStatus.Pending || + cfg.experimentStatus === ExperimentStatus.Running; + if (hasRightImg) { + const image = group.addShape('image', { + attrs: { + x: -10, + y: -10, + width: 20, + height: 20, + img: + cfg.experimentStatus === ExperimentStatus.Pending + ? require('@/assets/img/experiment-pending.png') + : require('@/assets/img/experiment-running.png'), + cursor: 'pointer', + }, + draggable: false, + capture: false, + }); + + if (cfg.experimentStatus === ExperimentStatus.Running) { + image.animate( + (ratio) => { + const toMatrix = Util.transform( + [1, 0, 0, 0, 1, 0, 0, 0, 1], + [ + ['r', ratio * Math.PI * 2], + ['t', width / 2 - 14 + 10, -height / 2 - 6 + 10], + ], + ); + return { + matrix: toMatrix, + }; + }, + { + repeat: true, // 动画重复 + duration: 1000, + easing: 'easeLinear', + }, + ); + } else if (cfg.experimentStatus === ExperimentStatus.Pending) { + const toMatrix = Util.transform( + [1, 0, 0, 0, 1, 0, 0, 0, 1], + [['t', width / 2 - 14 + 10, -height / 2 - 6 + 10]], + ); + image.setMatrix(toMatrix); + } + } + const bbox = group.getBBox(); + const anchorPoints = this.getAnchorPoints(cfg); + anchorPoints.forEach((anchorPos, i) => { + group.addShape('circle', { + attrs: { + r: 3, + x: bbox.x + bbox.width * anchorPos[0], + y: bbox.y + bbox.height * anchorPos[1], + fill: '#fff', + stroke: '#a4a4a5', + }, + name: `anchor-point`, // the name, for searching by group.find(ele => ele.get('name') === 'anchor-point') + anchorPointIdx: i, // flag the idx of the anchor-point circle + links: 0, // cache the number of edges connected to this shape + visible: false, // invisible by default, shows up when links > 1 or the node is in showAnchors state + }); + }); + return image; + }, + + // response the state changes and show/hide the link-point circles + setState(name, value, item) { + const group = item.getContainer(); + const shape = group.get('children')?.[0]; + if (name === 'hover') { + if (value) { + shape?.attr('stroke', themes['primaryColor']); + } else { + shape?.attr('stroke', 'transparent'); + } + } + }, + }, + 'rect', + ); + graph = new G6.Graph({ + container: graphRef.current, + grid: true, + width: graphRef.current.clientWidth || 500, + height: graphRef.current.clientHeight || 760, + animate: false, + groupByTypes: true, + enabledStack: false, + fitView: true, + minZoom: 0.5, + maxZoom: 5, + fitViewPadding: 200, + modes: { + default: [ + // config the shouldBegin for drag-node to avoid node moving while dragging on the anchor-point circles + { + type: 'drag-node', + shouldBegin: (e) => { + if (e.target.get('name') === 'anchor-point') return false; + return true; + }, + }, + // config the shouldBegin and shouldEnd to make sure the create-edge is began and ended at anchor-point circles + 'drag-canvas', + 'zoom-canvas', + ], + }, + + defaultNode: { + type: 'rect-node', + size: [width, height], + + labelCfg: { + style: { + fill: '#000', + fontSize: 10, + cursor: 'pointer', + x: -20, + y: 0, + textAlign: 'left', + textBaseline: 'middle', + }, + }, + style: { + fill: '#fff', + stroke: 'transparent', + cursor: 'pointer', + radius: 8, + shadowColor: 'rgba(75, 84, 137, 0.4)', + shadowBlur: 6, + shadowOffsetX: 0, + shadowOffsetY: 0, + overflow: 'hidden', + lineWidth: 0.5, + }, + }, + defaultEdge: { + style: { + endArrow: { + // 设置终点箭头 + path: G6.Arrow.triangle(3, 3, 3), // 使用内置箭头路径函数,参数为箭头的 宽度、长度、偏移量(默认为 0,与 d 对应) + d: 4.5, + fill: '#a2a6b5', + }, + cursor: 'pointer', + lineWidth: 1, + opacity: 1, + stroke: '#a2a6b5', + radius: 1, + }, + labelCfg: { + autoRotate: true, + style: { + fontSize: 10, + fill: '#FFF', + }, + }, + }, + }); + + // 修改历史数据样式问题 + graph.node((node) => { + return { + style: { + stroke: 'transparent', + radius: 8, + }, + }; + }); + + // 绑定事件 + bindEvents(); + }; + + // 绑定事件 + const bindEvents = () => { + const closeDrawer = () => { + closePropsDrawer(); + setTimeout(() => { + setExperimentNodeData(null); + }, 200); + }; + + graph.on('node:click', (e) => { + if (e.target.get('name') !== 'anchor-point' && e.item) { + const model = e.item.getModel(); + setExperimentNodeData(model); + openPropsDrawer(); + } + }); + graph.on('node:mouseenter', (e) => { + graph.setItemState(e.item, 'hover', true); + }); + graph.on('node:mouseleave', (e) => { + graph.setItemState(e.item, 'hover', false); + }); + graph.on('canvas:click', (e) => { + closeDrawer(); + }); + graph.on('edge:click', (e) => { + closeDrawer(); + }); + }; + + return ( +
+
+
+ 启动时间:{formatDate(workflowStatus?.startedAt)} +
+
+ 执行时长: + +
+
+ 状态: + {statusInfo ? ( + <> + + {statusInfo.label} + + ) : ( + '--' + )} +
+ +
+
+ {experimentIns && experimentNodeData ? ( + + ) : null} + +
+ ); +} +export default ExperimentText; diff --git a/react-ui/src/pages/Experiment/Info/index.less b/react-ui/src/pages/Experiment/Info/index.less new file mode 100644 index 00000000..70b27284 --- /dev/null +++ b/react-ui/src/pages/Experiment/Info/index.less @@ -0,0 +1,33 @@ +.pipeline-container { + height: 100%; + background-color: #fff; + + &__top { + display: flex; + align-items: center; + width: 100%; + height: 56px; + padding: 0 30px; + background: #ffffff; + box-shadow: 0px 3px 6px rgba(146, 146, 146, 0.09); + + &__info { + display: flex; + align-items: center; + margin-right: 30px; + color: rgba(29, 29, 32, 0.8); + font-size: 15px; + } + &__param-button { + margin-right: 0; + margin-left: auto; + } + } + &__graph { + width: 100%; + height: calc(100% - 56px); + background-color: @background-color; + background-image: url(@/assets/img/pipeline-canvas-bg.png); + background-size: 100% 100%; + } +} diff --git a/react-ui/src/pages/Experiment/Tensorboard/index.tsx b/react-ui/src/pages/Experiment/Tensorboard/index.tsx new file mode 100644 index 00000000..7124d0de --- /dev/null +++ b/react-ui/src/pages/Experiment/Tensorboard/index.tsx @@ -0,0 +1,12 @@ +/* + * @Author: 赵伟 + * @Date: 2025-03-31 16:38:59 + * @Description: 实验可视化 Tensorboard + */ + +import IframePage, { IframePageType } from '@/components/IFramePage'; + +function TensorboardPage() { + return ; +} +export default TensorboardPage; diff --git a/react-ui/src/pages/Experiment/components/AddExperimentModal/index.less b/react-ui/src/pages/Experiment/components/AddExperimentModal/index.less new file mode 100644 index 00000000..8cc0f830 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/AddExperimentModal/index.less @@ -0,0 +1,15 @@ +.add-experiment-modal { + .global_param_item { + max-height: 230px; + padding: 24px 12px 0; + overflow-y: auto; + border: 1px solid #e6e6e6; + border-radius: 6px; + } + + :global { + .ant-form-item-row { + align-items: center; + } + } +} diff --git a/react-ui/src/pages/Experiment/components/AddExperimentModal/index.tsx b/react-ui/src/pages/Experiment/components/AddExperimentModal/index.tsx new file mode 100644 index 00000000..4d576819 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/AddExperimentModal/index.tsx @@ -0,0 +1,231 @@ +import createExperimentIcon from '@/assets/img/create-experiment.png'; +import editExperimentIcon from '@/assets/img/edit-experiment.png'; +import KFModal from '@/components/KFModal'; +import { PipelineGlobalParamType, type PipelineGlobalParam } from '@/types'; +import { to } from '@/utils/promise'; +import { Button, Form, Input, Radio, Select, Typography, type FormRule } from 'antd'; +import { useState } from 'react'; +import styles from './index.less'; + +type FormData = { + name?: string; + description?: string; + workflow_id?: string | number; + global_param?: PipelineGlobalParam[]; +}; + +type AddExperimentModalProps = { + isAdd: boolean; + open: boolean; + onCancel: () => void; + onFinish: (values: any, isRun: boolean) => void; + workflowList: Workflow[]; + initialValues: FormData; +}; + +interface Workflow { + id: string | number; + name: string; + global_param?: PipelineGlobalParam[] | null; +} + +// 根据参数设置输入组件 +export const getParamComponent = (paramType: number): JSX.Element => { + // 防止后台返回不是 number 类型 + if (Number(paramType) === PipelineGlobalParamType.Boolean) { + return ( + + + + + ); + } + // if (isSensitive && Number(isSensitive) === 1) { + // return ; + // } + return ; +}; + +// 根据参数设置校验规则 +export const getParamRules = (paramType: number, required: boolean = false): FormRule[] => { + const rules = []; + // 防止后台返回不是 number 类型 + if (Number(paramType) === PipelineGlobalParamType.Number) { + rules.push({ + pattern: /^-?((0(\.0*[1-9]\d*)?)|([1-9]\d*(\.\d+)?))$/, + message: '整型必须是数字', + }); + } + if (required) { + rules.push({ required: true, message: '请输入值' }); + } + return rules; +}; + +// 根据参数设置 label +export const getParamLabel = (param: PipelineGlobalParam): React.ReactNode => { + const paramTypes: Readonly> = { + [PipelineGlobalParamType.String]: '字符串', + [PipelineGlobalParamType.Number]: '整型', + [PipelineGlobalParamType.Boolean]: '布尔类型', + }; + const label = param.param_name + `(${paramTypes[param.param_type]})`; + return {label}; +}; + +function AddExperimentModal({ + isAdd, + open, + onCancel, + onFinish, + workflowList = [], + initialValues = {}, +}: AddExperimentModalProps) { + const modalTitle = isAdd ? '新建实验' : '配置实验'; + const modalIcon = isAdd ? createExperimentIcon : editExperimentIcon; + const workflowDisabled = isAdd ? false : true; + const [globalParam, setGlobalParam] = useState( + initialValues.global_param || [], + ); + const [form] = Form.useForm(); + + const tailLayout = { + labelCol: { span: 24 }, + wrapperCol: { span: 24 }, + }; + + const layout = { + labelCol: { span: 5 }, + wrapperCol: { span: 19 }, + }; + + const paramLayout = { + labelCol: { span: 6 }, + wrapperCol: { span: 18 }, + }; + + // 除了流水线选择发生变化 + const handleWorkflowChange = (id: string | number) => { + const pipeline: Workflow | undefined = workflowList.find((v) => v.id === id); + if (pipeline && pipeline.global_param) { + setGlobalParam(pipeline.global_param); + form.setFieldValue('global_param', pipeline.global_param); + } else { + setGlobalParam([]); + form.setFieldValue('global_param', []); + } + }; + + const handleRun = async (run: boolean) => { + const [values, error] = await to(form.validateFields()); + if (!error && values) { + onFinish(values, run); + } + }; + + const footer = [ + , + , + ]; + if (!isAdd) { + footer.push( + , + ); + } + + return ( + +
+ + + + + + + + + + {globalParam.length > 0 && ( + +
+ + {(fields) => + fields.map(({ key, name, ...restField }) => ( + + {getParamComponent(globalParam[name]['param_type'])} + + )) + } + +
+
+ )} + +
+ ); +} + +export default AddExperimentModal; diff --git a/react-ui/src/pages/Experiment/components/ExperimentDrawer/index.less b/react-ui/src/pages/Experiment/components/ExperimentDrawer/index.less new file mode 100644 index 00000000..41cb8a19 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentDrawer/index.less @@ -0,0 +1,54 @@ +.experiment-drawer { + line-height: var(--ant-line-height); + :global { + .ant-drawer-body { + overflow-y: hidden; + } + + .ant-drawer-close { + position: absolute; + top: 16px; + right: 16px; + } + } + + &__tabs { + :global { + .ant-tabs-nav { + padding-left: 24px; + background-color: #f8fbff; + border: 1px solid #e0eaff; + } + .ant-tabs-content-holder { + .ant-tabs-content { + height: 100%; + .ant-tabs-tabpane { + height: 100%; + } + } + } + } + } + + &__info { + display: flex; + align-items: center; + margin-bottom: 15px; + padding: 0 24px; + color: @text-color; + font-size: 15px; + } + + &__status-dot { + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 50%; + } + + &__log { + height: 100%; + padding: 8px; + background: white; + } +} diff --git a/react-ui/src/pages/Experiment/components/ExperimentDrawer/index.tsx b/react-ui/src/pages/Experiment/components/ExperimentDrawer/index.tsx new file mode 100644 index 00000000..7a52060a --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentDrawer/index.tsx @@ -0,0 +1,164 @@ +import RunDuration from '@/components/RunDuration'; +import { ExperimentStatus } from '@/enums'; +import { experimentStatusInfo } from '@/pages/Experiment/status'; +import { PipelineNodeModelSerialize, type PipelineGlobalParam } from '@/types'; +import { formatDate } from '@/utils/date'; +import { CloseOutlined, DatabaseOutlined, ProfileOutlined } from '@ant-design/icons'; +import { Drawer, Tabs, Typography } from 'antd'; +import { useMemo } from 'react'; +import ExperimentParameter from '../ExperimentParameter'; +import ExperimentResult from '../ExperimentResult'; +import LogList from '../LogList'; +import styles from './index.less'; + +type ExperimentDrawerProps = { + open: boolean; + onClose: () => void; + pipelineId: number; // 流水线 id + experimentId: number; // 实验 id + experimentName: string; // 实验 name + instanceId: number; // 实验实例 id + instanceName: string; // 实验实例 name + instanceNamespace: string; // 实验实例 namespace + instanceNodeData: PipelineNodeModelSerialize; // 节点数据,在定时刷新实验实例状态中不会变化 + workflowId?: string; // 实验实例工作流 id + instanceNodeStatus?: ExperimentStatus; // 实例节点状态 + instanceNodeStartTime?: string; // 开始时间 + instanceNodeEndTime?: string; // 在定时刷新实验实例状态中,会经常变化 + globalParams?: PipelineGlobalParam[] | null; // 全局参数 +}; + +const ExperimentDrawer = ({ + open, + onClose, + pipelineId, + experimentId, + experimentName, + instanceId, + instanceName, + instanceNamespace, + instanceNodeData, + workflowId, + instanceNodeStatus, + instanceNodeStartTime, + instanceNodeEndTime, + globalParams, +}: ExperimentDrawerProps) => { + // 如果性能有问题,可以进一步拆解 + const items = useMemo( + () => [ + { + key: '1', + label: '日志详情', + children: ( +
+ +
+ ), + icon: , + }, + { + key: '2', + label: '配置参数', + icon: , + children: , + }, + { + key: '3', + label: '输出结果', + children: ( + + ), + icon: , + }, + ], + [ + instanceNodeData, + instanceId, + instanceName, + instanceNamespace, + instanceNodeStatus, + workflowId, + instanceNodeStartTime, + experimentName, + experimentId, + pipelineId, + globalParams, + ], + ); + + return ( + } + onClose={onClose} + open={open} + width={520} + className={styles['experiment-drawer']} + destroyOnClose={true} + mask={false} + > +
+
任务名称:{instanceNodeData.label}
+
+ 执行状态: + {instanceNodeStatus ? ( + <> +
+ + {experimentStatusInfo[instanceNodeStatus]?.label} + + + ) : ( + '--' + )} +
+ {instanceNodeData.message && ( +
+
消息:
+ + {instanceNodeData.message ?? '--'} + +
+ )} +
+ 启动时间:{formatDate(instanceNodeStartTime)} +
+
+ 耗时: + +
+
+ +
+ ); +}; + +export default ExperimentDrawer; diff --git a/react-ui/src/pages/Experiment/components/ExperimentInstanceList/index.less b/react-ui/src/pages/Experiment/components/ExperimentInstanceList/index.less new file mode 100644 index 00000000..5dea8f64 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentInstanceList/index.less @@ -0,0 +1,66 @@ +.tableExpandBox { + display: flex; + align-items: center; + width: 100%; + padding: 0 0 0 33px; + color: @text-color; + font-size: 14px; + + & > div { + padding: 0 16px; + } + + .check { + width: calc((100% + 32px + 33px) / 6.25 / 2); + } + + .index { + width: calc((100% + 32px + 33px) / 6.25 / 2); + } + + .tensorBoard { + width: calc((100% + 32px + 33px) / 6.25); + } + + .description { + display: flex; + flex: 1; + align-items: center; + + .startTime { + .singleLine(); + } + } + + .status { + width: 160px; + } + + .operation { + position: relative; + width: 344px; + } +} + +.tableExpandBoxContent { + height: 45px; + background-color: #fff; + border: 1px solid #eaeaea; + + & + & { + border-top: none; + } + + .statusBox { + display: flex; + align-items: center; + width: 160px; + } +} + +.loadMoreBox { + display: flex; + align-items: center; + justify-content: center; + margin: 16px auto 0; +} diff --git a/react-ui/src/pages/Experiment/components/ExperimentInstanceList/index.tsx b/react-ui/src/pages/Experiment/components/ExperimentInstanceList/index.tsx new file mode 100644 index 00000000..2d387e66 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentInstanceList/index.tsx @@ -0,0 +1,253 @@ +import KFIcon from '@/components/KFIcon'; +import { ExperimentStatus } from '@/enums'; +import { useCheck } from '@/hooks/useCheck'; +import { + deleteManyExperimentIns, + deleteQueryByExperimentInsId, + putQueryByExperimentInsId, +} from '@/services/experiment/index.js'; +import themes from '@/styles/theme.less'; +import { type ExperimentInstance } from '@/types'; +import { to } from '@/utils/promise'; +import { modalConfirm } from '@/utils/ui'; +import { DoubleRightOutlined } from '@ant-design/icons'; +import { App, Button, Checkbox, ConfigProvider } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useMemo } from 'react'; +import TensorBoardStatusCell from '../TensorBoardStatus'; +import styles from './index.less'; +import ExperimentInstanceComponent from './instance'; + +type ExperimentInstanceListProps = { + experimentInsList?: ExperimentInstance[]; + experimentInsTotal: number; + onClickInstance?: (instance: ExperimentInstance) => void; + onClickTensorBoard?: (instance: ExperimentInstance) => void; + onRemove?: () => void; + onTerminate?: (instance: ExperimentInstance) => void; + onLoadMore?: () => void; +}; + +function ExperimentInstanceList({ + experimentInsList, + experimentInsTotal, + onClickInstance, + onClickTensorBoard, + onRemove, + onTerminate, + onLoadMore, +}: ExperimentInstanceListProps) { + const { message } = App.useApp(); + const allIntanceIds = useMemo(() => { + return ( + experimentInsList + ?.filter( + (item) => + item.status !== ExperimentStatus.Running && item.status !== ExperimentStatus.Pending, + ) + .map((item) => item.id) || [] + ); + }, [experimentInsList]); + const [ + selectedIns, + setSelectedIns, + checked, + indeterminate, + checkAll, + isSingleChecked, + checkSingle, + ] = useCheck(allIntanceIds); + + useEffect(() => { + // 关闭时清空 + if (allIntanceIds.length === 0) { + setSelectedIns([]); + } + }, [allIntanceIds, setSelectedIns]); + + // 删除实验实例确认 + const handleRemove = (instance: ExperimentInstance) => { + modalConfirm({ + title: '删除后,该实验实例将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteExperimentInstance(instance.id); + }, + }); + }; + + // 删除实验实例 + const deleteExperimentInstance = async (id: number) => { + const [res] = await to(deleteQueryByExperimentInsId(id)); + if (res) { + message.success('删除成功'); + onRemove?.(); + } + }; + + // 批量删除实验实例确认 + const handleDeleteAll = () => { + modalConfirm({ + title: '确定批量删除选中的实例吗?', + onOk: () => { + batchDeleteExperimentInstances(); + }, + }); + }; + + // 批量删除实验实例 + const batchDeleteExperimentInstances = async () => { + const [res] = await to(deleteManyExperimentIns(selectedIns)); + if (res) { + message.success('删除成功'); + setSelectedIns([]); + onRemove?.(); + } + }; + + // 终止实验实例 + const handleTerminate = (instance: ExperimentInstance) => { + modalConfirm({ + title: '终止后,该次实验运行将不可恢复', + content: '是否确认终止?', + isDelete: false, + onOk: () => { + terminateExperimentInstance(instance); + }, + }); + }; + + // 终止实验实例 + const terminateExperimentInstance = async (instance: ExperimentInstance) => { + const [res] = await to(putQueryByExperimentInsId(instance.id)); + if (res) { + message.success('终止成功'); + onTerminate?.(instance); + } + }; + + if (!experimentInsList || experimentInsList.length === 0) { + return
暂无数据
; + } + + return ( +
+
+
+ +
+
序号
+
可视化
+
+
运行时长
+
开始时间
+
+
状态
+
+ 操作 + {selectedIns.length > 0 && ( + + )} +
+
+ + {experimentInsList.map((item, index) => ( +
+
+ checkSingle(item.id)} + disabled={ + item.status === ExperimentStatus.Running || item.status === ExperimentStatus.Pending + } + > +
+ onClickInstance?.(item)} + > + {index + 1} + +
+ {item.nodes_result?.tensorboard_log ? ( + onClickTensorBoard?.(item)} + > + ) : ( + '--' + )} +
+ + + +
+ + + + +
+
+ ))} + {experimentInsTotal > experimentInsList.length ? ( +
+ +
+ ) : null} +
+ ); +} + +export default ExperimentInstanceList; diff --git a/react-ui/src/pages/Experiment/components/ExperimentInstanceList/instance.tsx b/react-ui/src/pages/Experiment/components/ExperimentInstanceList/instance.tsx new file mode 100644 index 00000000..2c879ae7 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentInstanceList/instance.tsx @@ -0,0 +1,73 @@ +import RunDuration from '@/components/RunDuration'; +import { ExperimentStatus } from '@/enums'; +import { useSSE, type MessageHandler } from '@/hooks/useSSE'; +import { experimentStatusInfo } from '@/pages/Experiment/status'; +import { ExperimentInstance, NodeStatus } from '@/types'; +import { ExperimentCompleted } from '@/utils/constant'; +import { formatDate } from '@/utils/date'; +import { getWorkflowStatus } from '@/utils/experiment'; +import { Typography } from 'antd'; +import React, { useCallback } from 'react'; +import styles from './index.less'; + +type ExperimentInstanceComponentProps = { + instance: ExperimentInstance; +}; + +function ExperimentInstanceComponent({ instance }: ExperimentInstanceComponentProps) { + const { id, experiment_id, argo_ins_name, argo_ins_ns, nodes_status, create_time, finish_time } = + instance; + const workflowStatus = getWorkflowStatus(nodes_status) as NodeStatus | undefined; + const status = instance.status as ExperimentStatus; + const createTime = workflowStatus?.startedAt ?? create_time; + const finishTime = workflowStatus?.finishedAt ?? finish_time; + const statusInfo = experimentStatusInfo[status]; + + const handleSSEMessage: MessageHandler = useCallback( + (experimentId: number, experimentInsId: number, status: string, finishTime: string) => { + window.postMessage({ + type: ExperimentCompleted, + payload: { + experimentId, + experimentInsId, + status, + finishTime, + }, + }); + }, + [], + ); + useSSE(experiment_id, id, status, argo_ins_name, argo_ins_ns, handleSSEMessage); + + return ( + +
+
+ +
+
+ + {formatDate(createTime)} + +
+
+
+ {statusInfo ? ( + <> + + {statusInfo.label} + + ) : ( + '--' + )} +
+
+ ); +} + +export default ExperimentInstanceComponent; diff --git a/react-ui/src/pages/Experiment/components/ExperimentParameter/index.less b/react-ui/src/pages/Experiment/components/ExperimentParameter/index.less new file mode 100644 index 00000000..339ef362 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentParameter/index.less @@ -0,0 +1,38 @@ +.experiment-parameter { + height: 100%; + padding-top: 8px; + overflow-y: auto; + + &__title { + display: flex; + align-items: center; + height: 43px; + margin-right: 8px; + margin-bottom: 20px; + margin-left: 8px; + padding: 0 24px; + color: @text-color; + font-size: @font-size; + background: #f8fbff; + } + + &__form-list { + :global { + .ant-row { + padding: 0 !important; + } + } + + &:last-child { + :global { + .ant-form-item { + margin-bottom: 0 !important; + } + } + } + } + + &__list-empty { + color: @text-color-tertiary; + } +} diff --git a/react-ui/src/pages/Experiment/components/ExperimentParameter/index.tsx b/react-ui/src/pages/Experiment/components/ExperimentParameter/index.tsx new file mode 100644 index 00000000..80c6d25d --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentParameter/index.tsx @@ -0,0 +1,214 @@ +import FormInfo from '@/components/FormInfo'; +import ParameterSelect, { + type ParameterSelectDataType, + ParameterSelectTypeList, +} from '@/components/ParameterSelect'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { ComponentType } from '@/enums'; +import { setCurrentType } from '@/state/jcdResource'; +import type { + PipelineGlobalParam, + PipelineNodeModelParameter, + PipelineNodeModelSerialize, +} from '@/types'; +import { Flex, Form } from 'antd'; +import styles from './index.less'; + +type ExperimentParameterProps = { + nodeData: PipelineNodeModelSerialize; + globalParams?: PipelineGlobalParam[] | null; // 全局参数 +}; + +function ExperimentParameter({ nodeData, globalParams }: ExperimentParameterProps) { + // 云际组件,设置 store 当前资源类型 + if (nodeData.id.startsWith('remote-task')) { + const resourceType = nodeData.in_parameters['--resource_type'].value; + setCurrentType(resourceType); + } + + // 表单组件 + const getFormComponent = ( + item: { key: string; value: PipelineNodeModelParameter }, + parentName: string, + ) => { + return ( + + {item.value.type === ComponentType.Map && ( + + {(fields) => ( + <> + {fields.length > 0 ? ( + fields.map(({ key, name, ...restField }) => ( + + + + + = + + + + + )) + ) : ( +
+ )} + + )} +
+ )} + {item.value.type === ComponentType.Select && + (ParameterSelectTypeList.includes(item.value.item_type as ParameterSelectDataType) ? ( + + ) : null)} + {item.value.type !== ComponentType.Map && item.value.type !== ComponentType.Select && ( + + )} +
+ ); + }; + + // 基本参数 + const basicParametersList = Object.entries(nodeData.task_info ?? {}) + .map(([key, value]) => ({ + key, + value, + })) + .filter((v) => v.value.visible === true); + + // 控制策略 + const controlStrategyList = Object.entries(nodeData.control_strategy ?? {}) + .map(([key, value]) => ({ key, value })) + .filter((v) => v.value.visible === true); + + // 输入参数 + const inParametersList = Object.entries(nodeData.in_parameters ?? {}).map(([key, value]) => ({ + key, + value, + })); + + // 输出参数 + const outParametersList = Object.entries(nodeData.out_parameters ?? {}).map(([key, value]) => ({ + key, + value, + })); + + return ( +
+
+ +
+ + + + + + + + {basicParametersList.length + controlStrategyList.length > 0 && ( +
+ +
+ )} + + {/* 基本参数 */} + {basicParametersList.map((item) => getFormComponent(item, 'task_info'))} + + {/* 控制参数 */} + {controlStrategyList.map((item) => getFormComponent(item, 'control_strategy'))} + + {/* 输入参数 */} + {inParametersList.length > 0 && ( + <> +
+ +
+ {inParametersList.map((item) => getFormComponent(item, 'in_parameters'))} + + )} + + {/* 输出参数 */} + {outParametersList.length > 0 && ( + <> +
+ +
+ {outParametersList.map((item) => ( + + + + ))} + + )} + + ); +} + +export default ExperimentParameter; diff --git a/react-ui/src/pages/Experiment/components/ExperimentResult/index.less b/react-ui/src/pages/Experiment/components/ExperimentResult/index.less new file mode 100644 index 00000000..78684d72 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentResult/index.less @@ -0,0 +1,44 @@ +.experiment-result { + height: 100%; + padding: 8px; + overflow-y: auto; + color: @text-color; + font-size: 14px; + + &__content { + padding: 10px 20px 20px 20px; + background-color: rgba(234, 234, 234, 0.5); + } + + &__item { + margin-bottom: 20px; + &:last-child { + margin-bottom: 0; + } + + &__name { + display: flex; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid rgba(234, 234, 234, 0.8); + } + + &__file { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 10px; + padding: 0 20px 0 0; + + &:last-child { + margin-bottom: 0; + } + } + } + + &__empty { + margin-top: 10px; + text-align: center; + } +} diff --git a/react-ui/src/pages/Experiment/components/ExperimentResult/index.tsx b/react-ui/src/pages/Experiment/components/ExperimentResult/index.tsx new file mode 100644 index 00000000..1d7eedd9 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentResult/index.tsx @@ -0,0 +1,131 @@ +import { ResourceType } from '@/pages/Dataset/config'; +import { getNodeResult } from '@/services/experiment/index.js'; +import { downLoadZip } from '@/utils/downloadfile'; +import { openAntdModal } from '@/utils/modal'; +import { to } from '@/utils/promise'; +import { App, Button } from 'antd'; +import { useEffect, useState } from 'react'; +import ExportModelModal from '../ExportModelModal'; +import styles from './index.less'; + +type ExperimentResultProps = { + pipelineId: number; // 流水线 id + experimentId: number; // 实验 id + experimentName: string; // 实验 name + experimentInsId: number; // 实验实例 id + pipelineNodeId: string; // 流水线节点 id +}; + +type ExperimentResultData = { + name: string; + path: string; + type: string; + value: { + name: string; + size: string; + }[]; +}; + +function ExperimentResult({ + pipelineId, + experimentId, + experimentName, + experimentInsId, + pipelineNodeId, +}: ExperimentResultProps) { + const { message } = App.useApp(); + const [experimentResults, setExperimentResults] = useState([]); + const resourceType: ResourceType | undefined = pipelineNodeId.startsWith('general-data-process') + ? ResourceType.Dataset + : pipelineNodeId.startsWith('model-train') || + pipelineNodeId.startsWith('distributed-model-train') + ? ResourceType.Model + : undefined; + + useEffect(() => { + // 获取实验结果 + const getExperimentResult = async (params: any) => { + const [res] = await to(getNodeResult(params)); + if (res && res.data && Array.isArray(res.data)) { + const data = res.data.filter((item: ExperimentResultData) => item.value.length > 0); + setExperimentResults(data); + } else { + setExperimentResults([]); + } + }; + getExperimentResult({ id: `${experimentInsId}`, node_id: pipelineNodeId }); + }, [experimentInsId, pipelineNodeId]); + + // 下载 + const download = (path: string) => { + downLoadZip(`/api/mmp/minioStorage/download`, { path }); + }; + + // 导出到数据集、模型 + const exportToResource = (path: string) => { + const { close } = openAntdModal(ExportModelModal, { + resourceType: resourceType!, + pipelineId, + experimentId, + experimentName, + experimentInsId, + pipelineNodeId, + path, + onOk: () => { + message.success('导出成功'); + close(); + }, + }); + }; + + return ( +
+
+ {experimentResults.length > 0 ? ( + experimentResults.map((item) => ( +
+
+ {item.name} + + {resourceType && ( + + )} +
+
+ 文件名称 + 文件大小 +
+ {item.value?.map((ele) => ( +
+ {ele.name} + {ele.size} +
+ ))} +
+ )) + ) : ( +
暂无结果
+ )} +
+
+ ); +} + +export default ExperimentResult; diff --git a/react-ui/src/pages/Experiment/components/ExperimentStatusCell/index.less b/react-ui/src/pages/Experiment/components/ExperimentStatusCell/index.less new file mode 100644 index 00000000..67d76fc6 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentStatusCell/index.less @@ -0,0 +1,3 @@ +.experiment-status-cell { + height: 100%; +} diff --git a/react-ui/src/pages/Experiment/components/ExperimentStatusCell/index.tsx b/react-ui/src/pages/Experiment/components/ExperimentStatusCell/index.tsx new file mode 100644 index 00000000..2651b035 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExperimentStatusCell/index.tsx @@ -0,0 +1,33 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-18 18:35:41 + * @Description: 实验状态 + */ + +import { ExperimentStatus } from '@/enums'; +import { experimentStatusInfo as statusInfo } from '@/pages/Experiment/status'; +import styles from './index.less'; + +function ExperimentStatusCell(status?: ExperimentStatus | null) { + if (status === null || status === undefined || !statusInfo[status]) { + return --; + } + return ( +
+ + + {statusInfo[status]?.label} + +
+ ); +} + +export default ExperimentStatusCell; diff --git a/react-ui/src/pages/Experiment/components/ExportModelModal/index.less b/react-ui/src/pages/Experiment/components/ExportModelModal/index.less new file mode 100644 index 00000000..250f56a3 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExportModelModal/index.less @@ -0,0 +1,7 @@ +.export-model-modal__tooltip { + :global { + .ant-tooltip-inner { + white-space: pre-line; + } + } +} diff --git a/react-ui/src/pages/Experiment/components/ExportModelModal/index.tsx b/react-ui/src/pages/Experiment/components/ExportModelModal/index.tsx new file mode 100644 index 00000000..a49bf6a6 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ExportModelModal/index.tsx @@ -0,0 +1,218 @@ +import editExperimentIcon from '@/assets/img/edit-experiment.png'; +import KFModal from '@/components/KFModal'; +import { + DataSource, + ResourceType, + resourceConfig, + type ResourceData, +} from '@/pages/Dataset/config'; +import { to } from '@/utils/promise'; +import { Form, Input, ModalProps, Select } from 'antd'; +import { pick } from 'lodash'; +import { useEffect, useState } from 'react'; +import styles from './index.less'; + +type FormData = { + id: number; + version: string; + version_desc: string; +}; + +interface ExportModelModalProps extends Omit { + resourceType: ResourceType; + pipelineId: number; // 流水线 id + experimentId: number; // 实验 id + experimentName: string; // 实验 name + experimentInsId: number; // 实验实例 id + pipelineNodeId: string; // 流水线节点 id + path: string; // 文件路径 + onOk: () => void; +} + +function ExportModelModal({ + resourceType, + pipelineId, + experimentId, + experimentName, + experimentInsId, + pipelineNodeId, + path, + onOk, + ...rest +}: ExportModelModalProps) { + const [form] = Form.useForm(); + const [resources, setResources] = useState([]); + const config = resourceConfig[resourceType]; + + const layout = { + labelCol: { span: 24 }, + wrapperCol: { span: 24 }, + }; + + useEffect(() => { + // 获取数据集、模型列表 + const requestResourceList = async () => { + const params = { + page: 0, + size: 1000, + is_public: false, // 个人 + }; + const [res] = await to(config.getList(params)); + if (res && res.data) { + setResources(res.data.content || []); + } + }; + + requestResourceList(); + }, [config]); + + // 获取选中的数据集、模型 + const getSelectedResource = (id: number | undefined) => { + if (id) { + return resources.find((item) => item.id === id); + } + return undefined; + }; + + // 处理数据集、模型选择变化 + const handleResourceChange = (id: number | undefined) => { + if (id) { + getRecourceNextVersion(id); + } else { + form.setFieldValue('version', ''); + } + }; + + // 获取数据集、模型下一个版本 + const getRecourceNextVersion = async (id: number) => { + const resource = getSelectedResource(id); + if (!resource) { + return; + } + const [res] = await to(config.getNextVersion(pick(resource, ['identifier', 'owner']))); + if (res && res.data) { + form.setFieldValue('version', res.data); + } + }; + + // 提交 + const hanldeFinish = (formData: FormData) => { + exportToResource(formData); + }; + + // 导出到数据集、模型 + const exportToResource = async (formData: FormData) => { + const id = form.getFieldValue('id'); + const resource = getSelectedResource(id); + const params = { + ...formData, + identifier: resource?.identifier, + owner: resource?.owner, + is_public: resource?.is_public, + name: resource?.name, + [config.sourceParamKey]: DataSource.HandExport, + train_task: { + workflow_id: pipelineId, + experiment_id: experimentId, + name: experimentName, + ins_id: experimentInsId, + task_id: pipelineNodeId, + }, + [config.filePropKey]: [ + { + url: path, + }, + ], + }; + const [res] = await to(config.addVersion(params)); + if (res) { + onOk(); + } + }; + + return ( + +
+ + + + { + if (value === 'master') { + return Promise.reject(`${config.name}版本不能为 master`); + } else if (value === 'origin') { + return Promise.reject(`${config.name}版本不能为 origin`); + } else { + return Promise.resolve(); + } + }, + }, + ]} + > + + + + + + +
+ ); +} + +export default ExportModelModal; diff --git a/react-ui/src/pages/Experiment/components/LogGroup/index.less b/react-ui/src/pages/Experiment/components/LogGroup/index.less new file mode 100644 index 00000000..48012951 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/LogGroup/index.less @@ -0,0 +1,40 @@ +.log-group { + padding-bottom: 10px; + + &__pod { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + background: rgba(234, 234, 234, 0.5); + cursor: pointer; + + &__name { + margin-right: 10px; + color: @text-color; + font-size: 14px; + } + } + + &__detail { + padding: 15px; + color: white; + font-size: 14px; + font-family: 'Roboto Mono', 'Menlo', 'Consolas', 'Monaco', monospace; + white-space: pre-wrap; + text-align: left; + word-break: break-all; + background: #19253b; + + &--empty { + text-align: center; + } + } + + &__more-button { + display: flex; + justify-content: center; + color: white; + background: #19253b; + } +} diff --git a/react-ui/src/pages/Experiment/components/LogGroup/index.tsx b/react-ui/src/pages/Experiment/components/LogGroup/index.tsx new file mode 100644 index 00000000..7456bba1 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/LogGroup/index.tsx @@ -0,0 +1,260 @@ +/* + * @Author: 赵伟 + * @Date: 2024-05-16 08:47:46 + * @Description: 日志组件 + */ + +import { ExperimentStatus } from '@/enums'; +import { useStateRef } from '@/hooks/useStateRef'; +import { getExperimentPodsLog } from '@/services/experiment/index.js'; +import { DoubleRightOutlined, DownOutlined, UpOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import classNames from 'classnames'; +import dayjs from 'dayjs'; +import { useEffect, useRef, useState } from 'react'; +import { ExperimentLog } from '../LogList'; +import styles from './index.less'; + +export type LogGroupProps = ExperimentLog & { + status?: ExperimentStatus; // 实验状态 +}; + +type Log = { + start_time: string; // 日志开始时间 + log_content: string; // 日志内容 + pod_name: string; // pod名称 +}; + +function LogGroup({ + log_type = 'normal', + pod_name = '', + log_content = '', + start_time, + status, +}: LogGroupProps) { + const [collapse, setCollapse] = useState(true); + const [logList, setLogList] = useState([]); + const [completed, setCompleted] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_isMouseDown, setIsMouseDown, isMouseDownRef] = useStateRef(false); + const socketRef = useRef(undefined); + const retryRef = useRef(2); // 等待 2 秒,重试 3 次 + const logElementRef = useRef(null); + // 如果是【运行中】状态,设置 hasRun 为 true,【运行中】或者从【运行中】切换到别的状态时,不显示【更多】按钮 + const [hasRun, setHasRun] = useState(false); + if (status === ExperimentStatus.Running && !hasRun) { + setHasRun(true); + } + + // 进入页面时,滚动到底部 + useEffect(() => { + scrollToBottom(false); + }, []); + + useEffect(() => { + // 建立 socket 连接 + const setupSockect = () => { + let { host } = location; + if (process.env.NODE_ENV === 'development') { + host = '172.20.32.235:31213'; + } + const socket = new WebSocket( + `ws://${host}/newlog/realtimeLog?start=${start_time}&query={pod="${pod_name}"}`, + ); + + socket.addEventListener('open', () => { + console.log('WebSocket is open now.'); + }); + + socket.addEventListener('close', (event) => { + console.log('WebSocket is closed:', event); + // 有时候会出现连接失败,重试 3 次 + if (event.code !== 1000 && retryRef.current > 0) { + retryRef.current -= 1; + setTimeout(() => { + setupSockect(); + }, 2 * 1000); + } + }); + + socket.addEventListener('error', (event) => { + console.error('WebSocket error observed:', event); + }); + + socket.addEventListener('message', (event) => { + // console.log('message received.', event); + if (!event.data) { + return; + } + try { + const data = JSON.parse(event.data); + const streams = data.streams; + if (!streams || !Array.isArray(streams)) { + return; + } + let startTime = start_time; + const logContent = streams.reduce((result, item) => { + const values = item.values; + return ( + result + + values.reduce((prev: string, cur: [string, string]) => { + const [time, value] = cur; + startTime = time; + const str = `[${dayjs(Number(time) / 1.0e6).format( + 'YYYY-MM-DD HH:mm:ss', + )}] ${value}`; + return prev + str; + }, '') + ); + }, ''); + const logDetail: Log = { + start_time: startTime!, + log_content: logContent, + pod_name: pod_name, + }; + setLogList((oldList) => oldList.concat(logDetail)); + if (!isMouseDownRef.current && logContent) { + setTimeout(() => { + scrollToBottom(); + }, 100); + } + } catch (error) { + console.error('JSON parse error: ', error); + } + }); + + socketRef.current = socket; + }; + + // 关闭 socket + const closeSocket = () => { + if (socketRef.current) { + socketRef.current.close(1000, 'completed'); + socketRef.current = undefined; + } + }; + + if (status === ExperimentStatus.Running) { + setupSockect(); + } + + return () => { + closeSocket(); + }; + }, [status, start_time, pod_name, isMouseDownRef]); + + // 鼠标拖到中不滚动到底部 + useEffect(() => { + const mouseDown = () => { + setIsMouseDown(true); + }; + const mouseUp = () => { + setIsMouseDown(false); + }; + document.addEventListener('mousedown', mouseDown); + document.addEventListener('mouseup', mouseUp); + return () => { + document.removeEventListener('mousedown', mouseDown); + document.removeEventListener('mouseup', mouseUp); + }; + }, [setIsMouseDown]); + + // 请求日志 + const requestExperimentPodsLog = async () => { + const last = logList[logList.length - 1]; + const startTime = last ? last.start_time : start_time; + const params = { + pod_name, + start_time: startTime, + }; + const res = await getExperimentPodsLog(params); + const { log_detail } = res.data || {}; + if (log_detail) { + setLogList((oldList) => oldList.concat(log_detail)); + + if (!isMouseDownRef.current && log_detail.log_content) { + setTimeout(() => { + scrollToBottom(); + }, 100); + } + } + + // 判断是否日志是否加载完成 + if (!log_detail?.log_content) { + setCompleted(true); + } + }; + + // 处理折叠 + const handleCollapse = async () => { + if (!collapse) { + setCollapse(true); + return; + } + + if (logList.length === 0) { + try { + await requestExperimentPodsLog(); + setCollapse(false); + } catch (error) { + return Promise.reject(error); + } + } else { + setCollapse(false); + } + }; + + // 加载更多 + const loadMore = () => { + requestExperimentPodsLog(); + }; + + // 滚动到底部 + const scrollToBottom = (smooth: boolean = true) => { + // const element = document.getElementById(listId); + // if (element) { + // const optons: ScrollToOptions = { + // top: element.scrollHeight, + // behavior: smooth ? 'smooth' : 'instant', + // }; + // element.scrollTo(optons); + // } + logElementRef?.current?.scrollIntoView({ + block: 'end', + behavior: smooth ? 'smooth' : 'instant', + }); + }; + + const showLog = (log_type === 'resource' && !collapse) || log_type === 'normal'; + const logText = log_content + logList.map((v) => v.log_content).join(''); + const showMoreBtn = !hasRun && !completed && showLog && logText !== ''; + return ( +
+ {log_type === 'resource' && ( +
+
{pod_name}
+ {collapse ? : } +
+ )} + {showLog && ( +
+ {logText ? logText : '暂无日志'} +
+ )} +
+ {showMoreBtn && ( + + )} +
+
+ ); +} + +export default LogGroup; diff --git a/react-ui/src/pages/Experiment/components/LogList/index.less b/react-ui/src/pages/Experiment/components/LogList/index.less new file mode 100644 index 00000000..18fcb21f --- /dev/null +++ b/react-ui/src/pages/Experiment/components/LogList/index.less @@ -0,0 +1,19 @@ +.log-list { + height: 100%; + overflow-y: auto; + background: #19253b; + + &__empty { + padding: 15px; + color: white; + font-size: 14px; + white-space: pre-line; + text-align: center; + word-break: break-all; + background: #19253b; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.5); + } +} diff --git a/react-ui/src/pages/Experiment/components/LogList/index.tsx b/react-ui/src/pages/Experiment/components/LogList/index.tsx new file mode 100644 index 00000000..a06311d4 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/LogList/index.tsx @@ -0,0 +1,111 @@ +import { ExperimentStatus } from '@/enums'; +import { getQueryByExperimentLog } from '@/services/experiment/index.js'; +import { to } from '@/utils/promise'; +import classNames from 'classnames'; +import dayjs from 'dayjs'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import LogGroup from '../LogGroup'; +import styles from './index.less'; + +export type ExperimentLog = { + log_type: 'normal' | 'resource'; // 日志类型 + pod_name?: string; // 分布式名称 + log_content?: string; // 日志内容 + start_time?: string; // 日志开始时间 +}; + +type LogListProps = { + /** 实验实例 name */ + instanceName: string; + /** 实验实例 namespace */ + instanceNamespace: string; + /** 流水线节点 id */ + pipelineNodeId: string; + /** 实验实例工作流 id */ + workflowId?: string; + /** 实验实例节点开始运行时间 */ + instanceNodeStartTime?: string; + /** 实验实例节点运行状态 */ + instanceNodeStatus?: ExperimentStatus; + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; +}; + +function LogList({ + instanceName, + instanceNamespace, + pipelineNodeId, + workflowId, + instanceNodeStartTime, + instanceNodeStatus, + className, + style, +}: LogListProps) { + const [logGroups, setLogGroups] = useState([]); + const retryRef = useRef(3); // 等待 2 秒,重试 3 次 + + // 获取实验 Pods 组 + const getExperimentLog = useCallback(async () => { + const start_time = dayjs(instanceNodeStartTime).valueOf() * 1.0e6; + const params = { + task_id: pipelineNodeId, + component_id: workflowId, + name: instanceName, + namespace: instanceNamespace, + start_time: start_time, + }; + const [res] = await to(getQueryByExperimentLog(params)); + if (res && res.data) { + const { log_type, pods, log_detail } = res.data; + if (log_type === 'normal') { + const list = [ + { + ...log_detail, + log_type, + }, + ]; + setLogGroups(list); + } else if (log_type === 'resource') { + const list = pods.map((v: string) => ({ + log_type, + pod_name: v, + log_content: '', + start_time, + })); + setLogGroups(list); + } + } else { + if (retryRef.current > 0) { + retryRef.current -= 1; + setTimeout(() => { + getExperimentLog(); + }, 2 * 1000); + } + } + }, [pipelineNodeId, workflowId, instanceName, instanceNamespace, instanceNodeStartTime]); + + // 当实例节点运行状态不是 Pending,获取实验日志组 + useEffect(() => { + if ( + instanceNodeStatus && + instanceNodeStatus !== ExperimentStatus.Pending && + logGroups.length === 0 + ) { + getExperimentLog(); + } + }, [getExperimentLog, logGroups, instanceNodeStatus]); + + return ( +
+ {logGroups.length > 0 ? ( + logGroups.map((v) => ) + ) : ( +
暂无日志
+ )} +
+ ); +} + +export default LogList; diff --git a/react-ui/src/pages/Experiment/components/TensorBoardStatus/index.less b/react-ui/src/pages/Experiment/components/TensorBoardStatus/index.less new file mode 100644 index 00000000..1eec1b1a --- /dev/null +++ b/react-ui/src/pages/Experiment/components/TensorBoardStatus/index.less @@ -0,0 +1,26 @@ +.tensorBoard-status { + display: flex; + align-items: center; + color: rgba(29, 29, 32, 0.75); + + &__label { + color: rgba(29, 29, 32, 0.75); + font-size: 14px; + + &--running { + color: @success-color; + } + &--failed { + color: @error-color; + } + } + &__icon { + width: 14px; + color: @success-color; + cursor: pointer; + + & + & { + margin-left: 6px; + } + } +} diff --git a/react-ui/src/pages/Experiment/components/TensorBoardStatus/index.tsx b/react-ui/src/pages/Experiment/components/TensorBoardStatus/index.tsx new file mode 100644 index 00000000..b923dbd0 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/TensorBoardStatus/index.tsx @@ -0,0 +1,83 @@ +import exportImg from '@/assets/img/tensor-board-export.png'; +import pendingImg from '@/assets/img/tensor-board-pending.png'; +import { LoadingOutlined } from '@ant-design/icons'; +import classNames from 'classnames'; +import styles from './index.less'; +// import stopImg from '@/assets/img/tensor-board-stop.png'; +import terminatedImg from '@/assets/img/tensor-board-terminated.png'; +import { TensorBoardStatus } from '@/enums'; + +type TensorBoardStatusInfo = { + label: string; + icon: string; + classname: string; +}; + +const statusConfig: Record = { + Unknown: { + label: '未知', + icon: terminatedImg, + classname: '', + }, + Terminated: { + label: '未启动', + icon: terminatedImg, + classname: '', + }, + Failed: { + label: '失败', + icon: terminatedImg, + classname: 'tensorBoard-status__label--failed', + }, + Pending: { + label: '启动中', + icon: pendingImg, + classname: '', + }, + Running: { + label: '运行中', + icon: exportImg, + classname: 'tensorBoard-status__label--running', + }, +}; + +type TensorBoardStatusProps = { + status?: TensorBoardStatus; + onClick: () => void; +}; + +function TensorBoardStatusCell({ + status = TensorBoardStatus.Unknown, + onClick, +}: TensorBoardStatusProps) { + return ( +
+
+ {statusConfig[status].label} +
+ {statusConfig[status].icon ? ( + <> +
|
+ {status === TensorBoardStatus.Pending ? ( + + ) : ( + + )} + + ) : null} +
+ ); +} + +export default TensorBoardStatusCell; diff --git a/react-ui/src/pages/Experiment/components/ViewParamsModal/index.less b/react-ui/src/pages/Experiment/components/ViewParamsModal/index.less new file mode 100644 index 00000000..819ca7bf --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ViewParamsModal/index.less @@ -0,0 +1,20 @@ +.params-container { + max-height: calc(100vh - 300px); + padding: 24px 24px 0; + overflow-y: auto; + border: 1px solid #e6e6e6; + border-radius: 8px; + + :global { + .ant-form-item-row { + align-items: center; + } + } +} +.params-empty { + :global { + .kf-empty__image { + width: 300px; + } + } +} diff --git a/react-ui/src/pages/Experiment/components/ViewParamsModal/index.tsx b/react-ui/src/pages/Experiment/components/ViewParamsModal/index.tsx new file mode 100644 index 00000000..2000d556 --- /dev/null +++ b/react-ui/src/pages/Experiment/components/ViewParamsModal/index.tsx @@ -0,0 +1,70 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-09 15:59:14 + * @Description: 查看实验使用的参数 + */ +import parameterImg from '@/assets/img/modal-parameter.png'; +import KFEmpty, { EmptyType } from '@/components/KFEmpty'; +import KFModal from '@/components/KFModal'; +import { type PipelineGlobalParam } from '@/types'; +import { Form } from 'antd'; +import { getParamComponent, getParamLabel } from '../AddExperimentModal'; +import styles from './index.less'; + +type ParamsModalProps = { + open: boolean; + onCancel: () => void; + globalParams?: PipelineGlobalParam[] | null; +}; + +function ParamsModal({ open, onCancel, globalParams = [] }: ParamsModalProps) { + return ( + + {Array.isArray(globalParams) && globalParams.length > 0 ? ( +
+
+ + {(fields) => + fields.map(({ key, name, ...restField }) => ( + + {getParamComponent(globalParams[name]['param_type'])} + + )) + } + + +
+ ) : ( + + )} +
+ ); +} + +export default ParamsModal; diff --git a/react-ui/src/pages/Experiment/index.jsx b/react-ui/src/pages/Experiment/index.jsx new file mode 100644 index 00000000..d0f4b955 --- /dev/null +++ b/react-ui/src/pages/Experiment/index.jsx @@ -0,0 +1,682 @@ +import KFIcon from '@/components/KFIcon'; +import PageTitle from '@/components/PageTitle'; +import { ExperimentStatus, TensorBoardStatus } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { useServerTime } from '@/hooks/useServerTime'; +import { + deleteExperimentById, + editExperimentInsReq, + getExperiment, + getExperimentById, + getQueryByExperimentId, + getTensorBoardStatusReq, + postExperiment, + putExperiment, + runExperiments, + runTensorBoardReq, +} from '@/services/experiment/index.js'; +import { getWorkflow } from '@/services/pipeline/index.js'; +import themes from '@/styles/theme.less'; +import { ExperimentCompleted } from '@/utils/constant'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { modalConfirm } from '@/utils/ui'; +import { App, Button, ConfigProvider, Dropdown, Input, Space, Table, Tooltip } from 'antd'; +import classNames from 'classnames'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ComparisonType } from './Comparison/config'; +import AddExperimentModal from './components/AddExperimentModal'; +import ExperimentInstanceList from './components/ExperimentInstanceList'; +import styles from './index.less'; +import { experimentStatusInfo } from './status'; + +// 定时器 +const timerIds = new Map(); + +function Experiment() { + const navigate = useNavigate(); + const [experimentList, setExperimentList] = useState([]); + const [workflowList, setWorkflowList] = useState([]); + const [experimentId, setExperimentId] = useState(null); + const [experimentInsList, setExperimentInsList] = useState([]); + const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [total, setTotal] = useState(0); + const [isAdd, setIsAdd] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [addFormData, setAddFormData] = useState({}); + const [experimentInsTotal, setExperimentInsTotal] = useState(0); + const [cacheState, setCacheState] = useCacheState(); + const [searchText, setSearchText] = useState(cacheState?.searchText); + const [inputText, setInputText] = useState(cacheState?.searchText); + const [now] = useServerTime(); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 10, + }, + ); + const { message } = App.useApp(); + const timerRef = useRef(); + + // 获取实验列表 + const getExperimentList = useCallback( + async (skipLoading = false) => { + const params = { + page: pagination.current - 1, + size: pagination.pageSize, + name: searchText || undefined, + }; + const [res] = await to(getExperiment(params, skipLoading)); + if (res && res.data && Array.isArray(res.data.content)) { + setExperimentList( + res.data.content.map((item) => { + return { ...item, key: item.id }; + }), + ); + + setTotal(res.data.totalElements); + } + }, + [pagination, searchText], + ); + + // 刷新实验列表状态, + // 目前是直接刷新实验列表,后续需要优化,只刷新状态 + const refreshExperimentList = useCallback( + (skipLoading = false) => { + getExperimentList(skipLoading); + }, + [getExperimentList], + ); + + // 获取 TensorBoard 状态 + const getTensorBoardStatus = useCallback(async (experimentIn) => { + const params = { + namespace: experimentIn.nodes_result.tensorboard_log.namespace, + path: experimentIn.nodes_result.tensorboard_log.path, + pvc_name: experimentIn.nodes_result.tensorboard_log.pvc_name, + }; + const [res] = await to(getTensorBoardStatusReq(params)); + if (res && res.data) { + setExperimentInsList((prevList) => { + return prevList.map((item) => { + if (item.id === experimentIn.id) { + return { + ...item, + tensorBoardStatus: res.data.status, + tensorboardUrl: res.data.url, + }; + } + return item; + }); + }); + + let timerId = timerIds.get(experimentIn.id); + if (timerId) { + clearTimeout(timerId); + timerIds.delete(experimentIn.id); + } + timerId = setTimeout(() => { + getTensorBoardStatus(experimentIn); + }, 10 * 1000); + timerIds.set(experimentIn.id, timerId); + } + }, []); + + // 获取实验实例列表 + const getExperimentInsList = useCallback( + async (experimentId, page, size = 5, skipLoading = false) => { + const params = { + experimentId: experimentId, + page: page, + size: size, + }; + const [res, error] = await to(getQueryByExperimentId(params, skipLoading)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + try { + const list = content.map((v) => { + const nodes_result = v.nodes_result ? JSON.parse(v.nodes_result) : {}; + return { + ...v, + nodes_result, + }; + }); + if (page === 0) { + setExperimentInsList(list); + clearExperimentInTimers(); + } else { + setExperimentInsList((prev) => [...prev, ...list]); + } + setExperimentInsTotal(totalElements); + // 获取 TensorBoard 状态 + list.forEach((item) => { + if (item.nodes_result?.tensorboard_log) { + getTensorBoardStatus(item); + } + }); + } catch (error) { + console.error('JSON parse error: ', error); + } + } + }, + [getTensorBoardStatus], + ); + + // 刷新实验实例列表 + const refreshExperimentIns = useCallback( + (experimentId, skipLoading = false) => { + const length = experimentInsList.length; + getExperimentInsList(experimentId, 0, length, skipLoading); + }, + [experimentInsList, getExperimentInsList], + ); + + // 更新实验状态 + const editExperimentIns = useCallback( + async (experimentId, experimentInsId, status, argo_ins_name, argo_ins_ns) => { + const params = { + experiment_id: experimentId, + id: experimentInsId, + status: status, + argo_ins_name, + argo_ins_ns, + }; + const [res, error] = await to(editExperimentInsReq(params)); + if (res && res.data) { + refreshExperimentIns(experimentId, true); + refreshExperimentList(true); + } + }, + [refreshExperimentIns, refreshExperimentList], + ); + + // 获取流水线列表 + useEffect(() => { + // 获取流水线列表 + const getWorkflowList = async () => { + const queryFlow = { + offset: 1, + page: 0, + size: 10000, + name: null, + }; + const [res] = await to(getWorkflow(queryFlow)); + if (res && res.data && res.data.content) { + setWorkflowList(res.data.content); + } + }; + + getWorkflowList(); + return () => { + clearExperimentInTimers(); + }; + }, []); + // 获取实验列表 + useEffect(() => { + getExperimentList(); + }, [getExperimentList]); + + // 更新实验实例状态 + useEffect(() => { + const handleMessage = (e) => { + const { type, payload } = e.data; + if (type === ExperimentCompleted) { + const { experimentId, experimentInsId, status, finishTime } = payload; + const currentIns = experimentInsList.find((v) => v.id === experimentInsId); + // console.log( + // '实验实例状态变化', + // currentIns?.status, + // status, + // experimentId, + // experimentInsId, + // finishTime, + // ); + + if ( + !currentIns || + currentIns.status === ExperimentStatus.Terminated || + currentIns.status === status + ) { + return; + } + + editExperimentIns( + experimentId, + experimentInsId, + status, + currentIns.argo_ins_name, + currentIns.argo_ins_ns, + ); + + // refreshExperimentList(true); + // refreshExperimentIns(experimentId); + + // 修改实例的状态和结束时间 + // setExperimentInsList((prev) => + // prev.map((v) => + // v.id === experimentInsId + // ? { + // ...v, + // status: status, + // finish_time: finishTime, + // } + // : v, + // ), + // ); + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [experimentInsList, editExperimentIns]); + + // 搜索 + const onSearch = (value) => { + setSearchText(value); + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + // 运行 TensorBoard + const runTensorBoard = async (experimentIn) => { + const params = { + namespace: experimentIn.nodes_result.tensorboard_log.namespace, + path: experimentIn.nodes_result.tensorboard_log.path, + pvc_name: experimentIn.nodes_result.tensorboard_log.pvc_name, + }; + const [res] = await to(runTensorBoardReq(params)); + if (res) { + experimentIn.tensorboardUrl = res.data; + const timerId = timerIds.get(experimentIn.id); + if (timerId) { + clearTimeout(timerId); + timerIds.delete(experimentIn.id); + getTensorBoardStatus(experimentIn); + } + } + }; + + // 展开实例 + const expandChange = (expanded, record) => { + clearExperimentInTimers(); + setExperimentInsList([]); + if (expanded) { + setExpandedRowKeys([record.id]); + getExperimentInsList(record.id, 0, 5); + refreshExperimentList(); + } else { + setExpandedRowKeys([]); + } + }; + + // 终止实验实例获取 TensorBoard 状态的定时器 + const clearExperimentInTimers = () => { + timerIds.values().forEach((timerId) => { + clearTimeout(timerId); + }); + timerIds.clear(); + }; + + // 创建实验 + const createExperiment = () => { + setIsAdd(true); + setAddFormData({}); + setExperimentId(null); + setIsModalOpen(true); + }; + + // 编辑实验 + const editExperiment = (id) => { + getExperimentById(id).then((res) => { + setAddFormData({ + ...res.data, + }); + setExperimentId(res.data.id); + setIsAdd(false); + setIsModalOpen(true); + }); + }; + // 创建或编辑实验取消 + const handleCancel = () => { + setIsModalOpen(false); + }; + + // 创建或者编辑实验 + const handleAddExperiment = async (values, isRun) => { + const global_param = JSON.stringify(values.global_param); + if (!experimentId) { + const params = { + ...values, + global_param, + }; + const [res] = await to(postExperiment(params)); + if (res) { + message.success('新建实验成功'); + setIsModalOpen(false); + getExperimentList(); + } + } else { + const params = { ...values, global_param, id: experimentId }; + const [res] = await to(putExperiment(params)); + if (res) { + message.success('编辑实验成功'); + setIsModalOpen(false); + getExperimentList(); + + // 确定并运行 + if (isRun) { + runExperiment(experimentId); + } + } + } + }; + + // 当前页面切换 + const paginationChange = async (current, pageSize) => { + setPagination({ + current, + pageSize, + }); + }; + // 运行实验 + const runExperiment = async (id) => { + const [res] = await to(runExperiments(id)); + if (res) { + message.success('运行成功'); + setExpandedRowKeys([id]); + refreshExperimentList(); + getExperimentInsList(id, 0, 5); + } + }; + + // 跳转, 缓存当前状态 + const navigateToUrl = (url) => { + setCacheState({ + pagination, + searchText, + }); + navigate(url); + }; + + // 跳转到流水线 + const gotoPipeline = (record) => { + navigateToUrl(`/pipeline/template/info/${record.workflow_id}`); + }; + + // 跳转到实验实例详情 + const gotoInstanceInfo = (item, record) => { + navigateToUrl(`/pipeline/experiment/instance/${record.workflow_id}/${item.id}`); + }; + + // 处理 TensorBoard 操作 + const handleTensorboard = async (experimentIn) => { + if ( + experimentIn.tensorBoardStatus === TensorBoardStatus.Terminated || + experimentIn.tensorBoardStatus === TensorBoardStatus.Failed + ) { + await runTensorBoard(experimentIn); + } else if ( + experimentIn.tensorBoardStatus === TensorBoardStatus.Running && + experimentIn.tensorboardUrl + ) { + const url = experimentIn.tensorboardUrl; + SessionStorage.setItem(SessionStorage.tensorBoardUrlKey, url); + navigateToUrl(`/pipeline/experiment/visual`); + // window.open(experimentIn.tensorboardUrl, '_blank'); + } + }; + + // 实验实例终止 + const handleInstanceTerminate = async (experimentIn) => { + // 修改实例的状态和结束时间 + setExperimentInsList((prevList) => { + return prevList.map((item) => { + if (item.id === experimentIn.id) { + return { + ...item, + status: ExperimentStatus.Terminated, + finish_time: now().toISOString(), + }; + } + return item; + }); + }); + // 刷新实验列表和实例列表 + refreshExperimentList(true); + refreshExperimentIns(experimentIn.experiment_id); + }; + + // 实验对比菜单 + const getComparisonMenu = (experimentId) => { + return { + items: [ + { + label: 训练对比, + key: ComparisonType.Train, + }, + { + label: 评估对比, + key: ComparisonType.Evaluate, + }, + ], + onClick: ({ key }) => { + navigateToUrl(`/pipeline/experiment/compare?type=${key}&id=${experimentId}`); + }, + }; + }; + + // 加载更多实验实例 + const loadMoreExperimentIns = () => { + const page = Math.round(experimentInsList.length / 5); + getExperimentInsList(expandedRowKeys[0], page, 5); + }; + + // 处理删除 + const handleExperimentDelete = (record) => { + modalConfirm({ + title: '删除后,该实验将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteExperimentById(record.id).then((ret) => { + if (ret.code === 200) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: experimentList.length === 1 ? Math.max(1, prev.current - 1) : prev.current, + }; + }); + } else { + message.error(ret.msg); + } + }); + }, + }); + }; + + const columns = [ + { + title: '实验名称', + dataIndex: 'name', + key: 'name', + render: tableCellRender(false), + width: '16%', + }, + { + title: '关联流水线名称', + dataIndex: 'workflow_name', + key: 'workflow_name', + render: tableCellRender(false, TableCellValueType.Link, { + onClick: gotoPipeline, + }), + width: '16%', + }, + { + title: '实验描述', + dataIndex: 'description', + key: 'description', + render: tableCellRender(true), + }, + { + title: '最近五次运行状态', + dataIndex: 'status_list', + key: 'status_list', + width: 160, + render: (text) => { + const newText = text && text.replace(/\s+/g, '').split(','); + return ( + <> + {newText && newText.length > 0 + ? newText.map((item, index) => { + return ( + + + + ); + }) + : null} + + ); + }, + }, + { + title: '操作', + key: 'action', + width: 360, + render: (_, record) => ( + + + + + e.preventDefault()}> + + + 实验对比 + + + + + + + + ), + }, + ]; + + return ( +
+ +
+
+ setInputText(e.target.value)} + style={{ width: 300 }} + value={inputText} + allowClear + /> + +
+
+
`共${total}条`, + onChange: paginationChange, + }} + rowKey="id" + scroll={{ y: 'calc(100% - 55px)' }} + expandable={{ + expandedRowRender: (record) => ( + gotoInstanceInfo(item, record)} + onClickTensorBoard={handleTensorboard} + onRemove={() => { + refreshExperimentIns(record.id); + refreshExperimentList(); + }} + onTerminate={handleInstanceTerminate} + onLoadMore={() => loadMoreExperimentIns()} + > + ), + onExpand: expandChange, + expandedRowKeys: expandedRowKeys, + }} + /> + + + + {isModalOpen && ( + + )} + + ); +} +export default Experiment; diff --git a/react-ui/src/pages/Experiment/index.less b/react-ui/src/pages/Experiment/index.less new file mode 100644 index 00000000..0791397a --- /dev/null +++ b/react-ui/src/pages/Experiment/index.less @@ -0,0 +1,21 @@ +.experiment-list { + height: 100%; + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 20px 30px 0; + background-color: white; + border-radius: 10px; + + &__filter { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__table { + height: calc(100% - 32px - 28px); + margin-top: 28px; + } + } +} diff --git a/react-ui/src/pages/Experiment/status.ts b/react-ui/src/pages/Experiment/status.ts new file mode 100644 index 00000000..c6e2c0a5 --- /dev/null +++ b/react-ui/src/pages/Experiment/status.ts @@ -0,0 +1,53 @@ +import { ExperimentStatus } from '@/enums'; +import themes from '@/styles/theme.less'; + +export { ExperimentStatus }; + +export interface ExperimentStatusInfo { + label: string; + color: string; + icon: string; +} + +export const experimentStatusInfo: Record = { + Running: { + label: '运行中', + color: themes.primaryColor, + icon: '/assets/images/experiment-status/running-icon.png', + }, + Succeeded: { + label: '成功', + color: themes.successColor, + icon: '/assets/images/experiment-status/success-icon.png', + }, + Pending: { + label: '等待中', + color: themes.pendingColor, + icon: '/assets/images/experiment-status/pending-icon.png', + }, + Failed: { + label: '失败', + color: themes.errorColor, + icon: '/assets/images/experiment-status/fail-icon.png', + }, + Error: { + label: '错误', + color: themes.errorColor, + icon: '/assets/images/experiment-status/fail-icon.png', + }, + Terminated: { + label: '终止', + color: themes.abortColor, + icon: '/assets/images/experiment-status/omitted-icon.png', + }, + Skipped: { + label: '未执行', + color: themes.abortColor, + icon: '/assets/images/experiment-status/omitted-icon.png', + }, + Omitted: { + label: '未执行', + color: themes.abortColor, + icon: '/assets/images/experiment-status/omitted-icon.png', + }, +}; diff --git a/react-ui/src/pages/GitLink/index.tsx b/react-ui/src/pages/GitLink/index.tsx new file mode 100644 index 00000000..8646926d --- /dev/null +++ b/react-ui/src/pages/GitLink/index.tsx @@ -0,0 +1,7 @@ +import IframePage, { IframePageType } from '@/components/IFramePage'; + +function GitLink() { + return ; +} + +export default GitLink; diff --git a/react-ui/src/pages/Home/components/BlockTitle/index.less b/react-ui/src/pages/Home/components/BlockTitle/index.less new file mode 100644 index 00000000..8f0a72ed --- /dev/null +++ b/react-ui/src/pages/Home/components/BlockTitle/index.less @@ -0,0 +1,13 @@ +.block-title { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + + &__title { + color: @home-text-color; + font-weight: 500; + font-size: 2.25rem; + text-align: center; + } +} diff --git a/react-ui/src/pages/Home/components/BlockTitle/index.tsx b/react-ui/src/pages/Home/components/BlockTitle/index.tsx new file mode 100644 index 00000000..cfcd3f73 --- /dev/null +++ b/react-ui/src/pages/Home/components/BlockTitle/index.tsx @@ -0,0 +1,24 @@ +import classNames from 'classnames'; +import ViewMore from '../ViewMore'; +import styles from './index.less'; + +type BlockTitleProps = { + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; + /** 标题 */ + title: string; + onClick?: () => void; +}; + +function BlockTitle({ title, onClick, className, style }: BlockTitleProps) { + return ( +
+
{title}
+ +
+ ); +} + +export default BlockTitle; diff --git a/react-ui/src/pages/Home/components/CodeConfig/index.less b/react-ui/src/pages/Home/components/CodeConfig/index.less new file mode 100644 index 00000000..89ee9468 --- /dev/null +++ b/react-ui/src/pages/Home/components/CodeConfig/index.less @@ -0,0 +1,151 @@ +.code { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + padding: 4.375rem @home-padding-x 9.375rem; + .backgroundFullImage(url(@/assets/img/home/code-bg.png)); + + &__item { + position: relative; + width: calc((100% - 2 * 1.25rem) / 3); + padding: 1.625rem; + color: @home-text-color-tertiary; + font-size: 0.8125rem; + cursor: pointer; + + &__title { + flex: 1; + color: @home-text-color; + font-size: 1rem; + .singleLine(); + } + + &__arrow { + display: none; + width: 0.875rem; + margin-left: 0.5rem; + } + + &:hover &__arrow { + display: block; + } + + // &__type { + // width: 3.25rem; + // height: 1.375rem; + // color: white; + // font-size: 0.875rem; + // line-height: 1.375rem; + // text-align: center; + // border-radius: 0.75rem; + + // &--public { + // background: linear-gradient(120.77deg, @primary-color 0%, #79ffa7 100%); + // } + + // &--private { + // background: linear-gradient(127.67deg, #ffb716 0%, #e079ff 100%); + // } + // } + + // &:hover &__type--public { + // color: @primary-color; + // background: linear-gradient(120.77deg, #ffffff 0%, #d0ffe0 100%); + // } + + // &:hover &__type--private { + // color: @primary-color; + // background: linear-gradient(127.67deg, #e079ff 0%, #ffb716 100%); + // } + + &__desc { + height: 2.75rem; + margin-bottom: 0.875rem; + color: @home-text-color-secondary; + font-size: 0.875rem; + line-height: 1.375rem; + .multiLine(2); + } + + &__user-avatar { + flex: none; + width: 1.5rem; + height: 1.5rem; + margin-right: 0.875rem; + } + + &__user { + .singleLine(); + } + + &__user-divider { + flex: none; + height: 0.625rem; + margin-right: 0.75rem; + margin-left: 0.75rem; + background-color: @home-divider-color; + } + + &__timestamp-icon { + flex: none; + width: 1rem; + height: 1rem; + margin-right: 0.375rem; + } + + &__timestamp { + flex: none; + } + } + + &__item--first { + background-color: transparent; + border-radius: 1rem; + box-shadow: 0px 0px 0.75rem rgba(33, 73, 212, 0.15); + .backgroundFullImage(url(@/assets/img/home/code-item-bg.png)); + + &:hover { + .backgroundFullImage(url(@/assets/img/home/code-item-bg-hover.png)); + color: white; + box-shadow: 0px 0px 0.75rem rgba(33, 73, 212, 0.15); + } + } + + &__item--first &__item__arrow { + display: none !important; + } + + &__second-line { + position: relative; + margin-top: 1.625rem; + background-color: white; + border-radius: 1rem; + box-shadow: 0px 0px 0.75rem rgba(33, 73, 212, 0.15); + + &__divider { + position: absolute; + top: 1.625rem; + bottom: 1.625rem; + left: calc((100% - 2 * 1.25rem) / 3 + 0.625rem); + border-left: 1px dashed rgba(146, 164, 201, 0.56); + + &&--second { + right: calc((100% - 2 * 1.25rem) / 3 + 0.625rem); + left: auto; + } + } + } + + &__item--first:hover &__item__title { + color: white; + } + + &__item--first:hover &__item__desc { + color: white; + } + + &__item--second:hover &__item__title { + color: @primary-color; + } +} diff --git a/react-ui/src/pages/Home/components/CodeConfig/index.tsx b/react-ui/src/pages/Home/components/CodeConfig/index.tsx new file mode 100644 index 00000000..cf0b6ea2 --- /dev/null +++ b/react-ui/src/pages/Home/components/CodeConfig/index.tsx @@ -0,0 +1,94 @@ +import { CodeConfigData } from '@/components/CodeSelectorModal'; +import { getPublicCodeConfigsReq } from '@/services/home'; +import { getGitUrl } from '@/utils'; +import { formatDate } from '@/utils/date'; +import { to } from '@/utils/promise'; +import { gotoPageIfLogin } from '@/utils/ui'; +import { Divider, Flex } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; +import BlockTitle from '../BlockTitle'; +import styles from './index.less'; + +function CodeConfig() { + const [codeCofigs, setCodeConfigs] = useState([]); + + useEffect(() => { + const getPublicCodeConfigs = async () => { + const [res] = await to(getPublicCodeConfigsReq()); + if (res && res.data) { + const { content = [] } = res.data; + setCodeConfigs(content.slice(0, 9)); + } + }; + + getPublicCodeConfigs(); + }, []); + + const createItem = (item: CodeConfigData, className: string) => { + return ( +
{ + const url = getGitUrl(item.git_url, item.git_branch); + window.open(url, '_blank'); + }} + > + +
{item.code_repo_name}
+ +
+
{item.git_url}
+ + +
{item.create_by}
+ + +
{formatDate(item.create_time)}
+
+
+
+ ); + }; + + return ( +
+ gotoPageIfLogin('/dataset/codeConfig')} + > + + {codeCofigs.slice(0, 3).map((item) => createItem(item, styles['code__item--first']))} + + + {codeCofigs.slice(3).map((item) => createItem(item, styles['code__item--second']))} +
+
+
+
+ ); +} + +export default CodeConfig; diff --git a/react-ui/src/pages/Home/components/Dataset/index.less b/react-ui/src/pages/Home/components/Dataset/index.less new file mode 100644 index 00000000..686dfa96 --- /dev/null +++ b/react-ui/src/pages/Home/components/Dataset/index.less @@ -0,0 +1,92 @@ +.dataset { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + padding: 0 @home-padding-x 11.125rem; + + &__item { + position: relative; + width: calc((100% - 3 * 1.25rem) / 4); + padding: 1.625rem; + color: @home-text-color-tertiary; + font-size: 0.8125rem; + background-color: transparent; + border-radius: 1rem; + box-shadow: 0px 0px 0.75rem rgba(33, 73, 212, 0.06); + cursor: pointer; + .backgroundFullImage(url(@/assets/img/home/dataset-item-bg.png)); + + &:nth-child(1), + &:nth-child(2), + &:nth-child(3) { + width: calc((100% - 2 * 1.25rem) / 3); + } + &:hover { + outline: 2px solid @primary-color; + box-shadow: 0px 0px 0.75rem rgba(33, 73, 212, 0.15); + } + + &__title { + flex: 1; + color: @home-text-color; + font-size: 1rem; + .singleLine(); + } + + &:hover &__title { + color: @primary-color; + } + + &__hot { + width: 3.25rem; + height: 1.375rem; + margin-left: 0.5rem; + color: white; + font-size: 0.875rem; + line-height: 1.375rem; + text-align: center; + background: linear-gradient(127.67deg, @primary-color 0%, #e079ff 100%); + border-radius: 0.6875rem; + } + + &__desc { + height: 2.75rem; + margin-bottom: 0.875rem; + color: @home-text-color-secondary; + font-size: 0.875rem; + line-height: 1.375rem; + .multiLine(2); + } + + &__user-avatar { + flex: none; + width: 1.5rem; + height: 1.5rem; + margin-right: 0.875rem; + } + + &__user { + .singleLine(); + } + + &__user-divider { + flex: none; + height: 0.625rem; + margin-right: 0.75rem; + margin-left: 0.75rem; + background-color: @home-divider-color; + } + + &__timestamp-icon { + flex: none; + width: 1rem; + height: 1rem; + margin-right: 0.375rem; + } + + &__timestamp { + flex: none; + } + } +} diff --git a/react-ui/src/pages/Home/components/Dataset/index.tsx b/react-ui/src/pages/Home/components/Dataset/index.tsx new file mode 100644 index 00000000..9bdd4852 --- /dev/null +++ b/react-ui/src/pages/Home/components/Dataset/index.tsx @@ -0,0 +1,73 @@ +import { DatasetData } from '@/pages/Dataset/config'; +import { getPublicDatasetsReq } from '@/services/home'; +import { to } from '@/utils/promise'; +import { gotoPageIfLogin } from '@/utils/ui'; +import { Divider, Flex } from 'antd'; +import { useEffect, useState } from 'react'; +import BlockTitle from '../BlockTitle'; +import styles from './index.less'; + +function DatasetBlock() { + const [datasetData, setDatasetData] = useState([]); + + useEffect(() => { + const getPublicDatasets = async () => { + const [res] = await to(getPublicDatasetsReq()); + if (res && res.data) { + const { content = [] } = res.data; + setDatasetData(content.slice(0, 7)); + } + }; + + getPublicDatasets(); + }, []); + + return ( +
+ gotoPageIfLogin('/dataset/dataset')} + > + + {datasetData.map((item, index) => { + return ( +
{ + gotoPageIfLogin( + `/dataset/dataset/info/${item.id}?name=${item.name}&owner=${item.owner}&identifier=${item.identifier}&is_public=${item.is_public}`, + ); + }} + > + +
{item.name}
+ {index < 3 &&
HOT
} +
+
{item.description}
+ + +
{item.create_by}
+ + +
{item.time_ago}更新
+
+
+ ); + })} +
+
+ ); +} + +export default DatasetBlock; diff --git a/react-ui/src/pages/Home/components/Footer/index.less b/react-ui/src/pages/Home/components/Footer/index.less new file mode 100644 index 00000000..efb201f8 --- /dev/null +++ b/react-ui/src/pages/Home/components/Footer/index.less @@ -0,0 +1,44 @@ +.footer { + width: 100%; + padding: 4.375rem 15.625rem 1.25rem; + color: .addAlpha(@home-text-color, 0.6) []; + font-size: 0.9375rem; + font-size: 0.75rem; + .backgroundFullImage(url(@/assets/img/home/footer-bg.png)); + + &__app-logo { + width: 1.875rem; + margin-right: 0.625rem; + } + + &__app-name { + margin-right: 5rem; + color: @home-text-color; + font-size: 1.5rem; + font-family: WenYiHei; + } + + &__about-us { + width: 24.75rem; + } + + &__contact { + align-self: start; + } + + &__title { + margin-bottom: 1rem; + color: @home-text-color; + font-size: 0.875rem; + } + + &__desc { + line-height: 1.5rem; + } + + &__copyright { + width: 100%; + color: @home-text-color-secondary; + text-align: center; + } +} diff --git a/react-ui/src/pages/Home/components/Footer/index.tsx b/react-ui/src/pages/Home/components/Footer/index.tsx new file mode 100644 index 00000000..367e253d --- /dev/null +++ b/react-ui/src/pages/Home/components/Footer/index.tsx @@ -0,0 +1,33 @@ +import { Divider, Flex } from 'antd'; +import styles from './index.less'; + +function Footer() { + return ( +
+ + + + 智能材料科研平台 + +
+
关于我们
+
+ 我是关于我们的文案简介内容我是关于我们的文案简介内容我是关于我们的文案简介内容我是关于我们的文案简介内容我是关于我们的文案简介内容我是关于我们的文案简介内容我是关于我们的文案简介内容我是关于我们的文案简介内容 +
+
+
+
联系我们
+
邮箱:xxxx@163.com
+
地址:湖南省长沙市岳麓区中电软件园
+
+
+ +
© 2025 国防科技大学所有
+
+ ); +} + +export default Footer; diff --git a/react-ui/src/pages/Home/components/Intro/index.less b/react-ui/src/pages/Home/components/Intro/index.less new file mode 100644 index 00000000..6adf597c --- /dev/null +++ b/react-ui/src/pages/Home/components/Intro/index.less @@ -0,0 +1,69 @@ +.intro { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 100; + height: @home-info-height; + overflow: hidden; + background-color: transparent; + background-repeat: no-repeat; + background-position: left top; + background-size: 100% 100%; + + &__content { + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + padding: 1.25rem @home-padding-x 0; + // background-image: url(@/assets/img/home/header-bg.png); + background-repeat: no-repeat; + background-position: left top; + background-size: 100% 100%; + } + + &__title { + margin-top: 1.25rem; + margin-bottom: 1.125rem; + color: #ffffff; + font-weight: 400; + font-size: 2.375rem; + font-family: WenYiHei; + } + + &__desc { + width: 54.5rem; + margin-bottom: 1.875rem; + color: #ffffff; + font-size: 1rem; + line-height: 1.75rem; + text-align: center; + } + + &__button { + margin-bottom: 4.375rem; + padding: 0.75rem 2.375rem; + color: #ffffff; + font-size: 1rem; + text-align: center; + background: linear-gradient( + 136.87deg, + rgba(57, 217, 255, 0.51) 0%, + rgba(255, 255, 255, 0.01) 48.54%, + rgba(255, 149, 247, 0.33) 100% + ); + border: 1px solid rgba(255, 255, 255, 0.38); + border-radius: 0.5rem; + cursor: pointer; + + &:hover { + background: linear-gradient( + 108.54deg, + rgba(183, 131, 255, 0.81) 3.72%, + rgba(119, 208, 255, 0.31) 98.01% + ); + } + } +} diff --git a/react-ui/src/pages/Home/components/Intro/index.tsx b/react-ui/src/pages/Home/components/Intro/index.tsx new file mode 100644 index 00000000..7ea944c1 --- /dev/null +++ b/react-ui/src/pages/Home/components/Intro/index.tsx @@ -0,0 +1,81 @@ +import miniHeaderImage from '@/assets/img/home/header-bg-mini.png'; +import headerImage from '@/assets/img/home/header-bg.png'; +import { convertRemToPx } from '@/utils'; +import { gotoPageIfLogin } from '@/utils/ui'; +import { + motion, + useMotionTemplate, + useMotionValueEvent, + useScroll, + useSpring, + useTransform, +} from 'motion/react'; +import { useState } from 'react'; +import NavBar from '../NavBar'; +import StatisticsBlock from '../Statistics'; +import styles from './index.less'; + +function IntroBlock() { + const [backgroundImage1, setBackgroundImage1] = useState(undefined); + const [backgroundImage2, setBackgroundImage2] = useState(headerImage); + const { scrollY } = useScroll(); + const springValue = useSpring(scrollY, { + stiffness: 100, + damping: 30, + restDelta: 0.001, + }); + + const initialHeight = convertRemToPx(35); + const minHeight = convertRemToPx(4.7); + const height = useTransform(() => `max(calc(35rem - ${springValue.get()}px), 4.7rem)`); + const left = useTransform(springValue, [0, initialHeight], [0.0, 16.25]); + const leftRem = useMotionTemplate`${left}rem`; + const radius = useTransform(springValue, [0, initialHeight], [0.0, 2.5]); + const radiusRem = useMotionTemplate`${radius}rem`; + const top = useTransform(springValue, [0, initialHeight], [0, 10]); + const paddingX = useTransform(springValue, [0, initialHeight], [16.25, 10]); + const paddingXRem = useMotionTemplate`1.25rem ${paddingX}rem 0`; + useMotionValueEvent(scrollY, 'change', (value) => { + setBackgroundImage1(value > initialHeight - minHeight ? miniHeaderImage : undefined); + setBackgroundImage2(value > initialHeight - minHeight ? undefined : headerImage); + }); + + return ( + + + +
智能材料科研平台
+
+ 智能材料科研平台是用于材料研究和开发的技术平台,它旨在提供实验数据收集、分析和可视化等功能, + 以支持材料工程师、科学家和研究人员在材料设计、性能评估和工艺优化方面的工作。 +
+
gotoPageIfLogin('/workspace')}> + 开始使用 +
+ +
+
+ ); +} + +export default IntroBlock; diff --git a/react-ui/src/pages/Home/components/Mirror/index.less b/react-ui/src/pages/Home/components/Mirror/index.less new file mode 100644 index 00000000..a88a2393 --- /dev/null +++ b/react-ui/src/pages/Home/components/Mirror/index.less @@ -0,0 +1,110 @@ +.mirror { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + padding: 0 @home-padding-x 11.125rem; + + &__item { + position: relative; + width: calc((100% - 2 * 1.25rem) / 4); + padding: 1.625rem; + color: @home-text-color-tertiary; + font-size: 0.8125rem; + background-color: transparent; + border-radius: 1.125rem; + box-shadow: 0px 0.1875rem 0.75rem rgba(41, 50, 225, 0.09); + cursor: pointer; + .backgroundFullImage(url(@/assets/img/home/dataset-item-bg.png)); + + &:nth-child(2), + &:nth-child(4) { + width: calc((100% - 2 * 1.25rem) / 2); + } + &:hover { + outline: 2px solid @primary-color; + box-shadow: 0px 0.1875rem 0.75rem rgba(41, 50, 225, 0.09); + } + + &__title { + flex: 1; + margin-right: 0.5rem; + color: @home-text-color; + font-size: 1rem; + .singleLine(); + } + + &:hover &__title { + color: @primary-color; + } + + &__arrow { + display: none; + width: 0.75rem; + height: 0.75rem; + } + + &:hover &__arrow { + display: block; + } + + &__desc { + height: 2.75rem; + margin-bottom: 0.875rem; + color: @home-text-color-secondary; + font-size: 0.875rem; + line-height: 1.375rem; + .multiLine(2); + } + + &__version { + width: fit-content; + margin-bottom: 1.625rem; + padding: 0.375rem 0.625rem; + color: @primary-color; + font-size: 0.875rem; + background: linear-gradient( + 90deg, + .addAlpha(@primary-color, 0.1) [] 0%, + .addAlpha(#c7daff, 0.1) [] 100% + ); + border-radius: 0.25rem; + + &__img { + width: 0.875rem; + height: 0.875rem; + margin-right: 0.375rem; + } + } + + &__user-avatar { + flex: none; + width: 1.5rem; + height: 1.5rem; + margin-right: 0.875rem; + } + + &__user { + .singleLine(); + } + + &__user-divider { + flex: none; + height: 0.625rem; + margin-right: 0.75rem; + margin-left: 0.75rem; + background-color: @home-divider-color; + } + + &__timestamp-icon { + flex: none; + width: 1rem; + height: 1rem; + margin-right: 0.375rem; + } + + &__timestamp { + flex: none; + } + } +} diff --git a/react-ui/src/pages/Home/components/Mirror/index.tsx b/react-ui/src/pages/Home/components/Mirror/index.tsx new file mode 100644 index 00000000..b7fcc7c2 --- /dev/null +++ b/react-ui/src/pages/Home/components/Mirror/index.tsx @@ -0,0 +1,82 @@ +import { MirrorData } from '@/pages/Mirror/List'; +import { getPublicImagesReq } from '@/services/home'; +import { formatDate } from '@/utils/date'; +import { to } from '@/utils/promise'; +import { gotoPageIfLogin } from '@/utils/ui'; +import { Divider, Flex } from 'antd'; +import { useEffect, useState } from 'react'; +import BlockTitle from '../BlockTitle'; +import styles from './index.less'; + +function MirrorBlock() { + const [mirrorData, setMirrirData] = useState([]); + + useEffect(() => { + const getPublicImages = async () => { + const [res] = await to(getPublicImagesReq()); + if (res && res.data) { + const { content = [] } = res.data; + setMirrirData(content.slice(0, 6)); + } + }; + + getPublicImages(); + }, []); + + return ( +
+ gotoPageIfLogin('/dataset/mirror')} + > + + {mirrorData.map((item) => { + return ( +
{ + gotoPageIfLogin(`/dataset/mirror/info/${item.id}`); + }} + > + + +
{item.name}
+ +
+
{item.description}
+ + + {`版本数:${item.version_count}`} + + +
{item.create_by}
+ + +
+ {formatDate(item.create_time)} +
+
+
+
+ ); + })} +
+
+ ); +} + +export default MirrorBlock; diff --git a/react-ui/src/pages/Home/components/Model/index.less b/react-ui/src/pages/Home/components/Model/index.less new file mode 100644 index 00000000..bf2e8ba4 --- /dev/null +++ b/react-ui/src/pages/Home/components/Model/index.less @@ -0,0 +1,113 @@ +.model { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + padding: 4.125rem @home-padding-x 14.75rem; + .backgroundFullImage(url(@/assets/img/home/model-bg.png)); + + &__content { + display: flex; + flex-wrap: wrap; + gap: 1.625rem 1.25rem; + align-items: center; + width: 100%; + } + + &__item { + position: relative; + display: flex; + align-items: center; + width: calc((100% - 2 * 1.25rem) / 3); + padding: 1.875rem 1.25rem; + color: @home-text-color-tertiary; + font-size: 0.8125rem; + background-color: transparent; + border-radius: 1rem; + box-shadow: 0px 0.0625rem 0.75rem rgba(33, 73, 212, 0.09); + cursor: pointer; + .backgroundFullImage(url(@/assets/img/home/model-item-bg.png)); + + &:hover { + color: white; + .backgroundFullImage(url(@/assets/img/home/model-item-bg-hover2.png)); + } + + &__hot { + .backgroundFullImage(url(@/assets/img/home/model-item-hot.png)); + position: absolute; + top: 0; + right: 0; + width: 4.625rem; + height: 2rem; + } + + &:hover &__hot { + .backgroundFullImage(url(@/assets/img/home/model-item-hot-hover.png)); + } + + &__user-avatar { + flex: none; + width: 2.75rem; + height: 2.75rem; + margin-right: 0.875rem; + } + + &__title { + margin-bottom: 0.625rem; + color: @home-text-color; + font-size: 1rem; + .singleLine(); + } + + &:hover &__title { + color: white; + } + + &__desc { + height: 2.75rem; + margin-bottom: 0.875rem; + font-size: 0.875rem; + line-height: 1.375rem; + .multiLine(2); + } + + &__user { + .singleLine(); + } + + &__user-divider { + flex: none; + height: 0.625rem; + margin-right: 0.75rem; + margin-left: 0.75rem; + background-color: @home-divider-color; + } + + &__timestamp { + flex: none; + } + + &:hover &__user-divider { + background-color: white; + } + + // &__category { + // padding: 0.25rem 0.625rem; + // color: @primary-color; + // font-size: 0.8125rem; + // background-color: .addAlpha(@primary-color, 0.07) []; + // border-radius: 0.25rem; + + // &:nth-child(2) { + // color: rgba(28, 153, 7, 1); + // background-color: rgba(28, 153, 7, 0.07); + // } + // } + + // &:hover &__category { + // color: @home-text-color; + // background-color: white; + // } + } +} diff --git a/react-ui/src/pages/Home/components/Model/index.tsx b/react-ui/src/pages/Home/components/Model/index.tsx new file mode 100644 index 00000000..1d7c8c6a --- /dev/null +++ b/react-ui/src/pages/Home/components/Model/index.tsx @@ -0,0 +1,108 @@ +import { ModelData } from '@/pages/Dataset/config'; +import { getPublicModelsReq } from '@/services/home'; +import { to } from '@/utils/promise'; +import { gotoPageIfLogin } from '@/utils/ui'; +import { Divider, Flex } from 'antd'; +import { motion, useMotionValueEvent, useScroll, type Variants } from 'motion/react'; +import { useEffect, useState } from 'react'; +import BlockTitle from '../BlockTitle'; +import styles from './index.less'; + +const modelVariants: Variants = { + offscreen: (down: boolean) => ({ + y: 100, + opacity: 0, + transition: { + ease: 'linear', + duration: 0.5, + }, + }), + onscreen: { + y: 0, + opacity: 1, + transition: { + ease: 'easeOut', + duration: 0.5, + // times: [0, 0, 1], + }, + }, +}; + +function ModelBlock() { + const [modelData, setModelData] = useState([]); + const [isDowning, setIsDowning] = useState(true); + const { scrollYProgress } = useScroll(); + useMotionValueEvent(scrollYProgress, 'change', (value) => { + setIsDowning((scrollYProgress.getPrevious() ?? 0) - value < 0); + }); + + useEffect(() => { + console.log(isDowning); + }, [isDowning]); + + useEffect(() => { + const getPublicModels = async () => { + const [res] = await to(getPublicModelsReq()); + if (res && res.data) { + const { content = [] } = res.data; + setModelData(content.slice(0, 6)); + } + }; + + getPublicModels(); + }, []); + + return ( +
+ gotoPageIfLogin('/dataset/model')} + > +
+ {modelData.map((item, index) => { + return ( + { + gotoPageIfLogin( + `/dataset/model/info/${item.id}?name=${item.name}&owner=${item.owner}&identifier=${item.identifier}&is_public=${item.is_public}`, + ); + }} + > + + {index < 3 &&
} +
+
{item.name}
+
{item.description}
+ +
{item.create_by}
+ +
{item.time_ago}更新
+
+
+ {/* +
电池开发
+
材料研发
+
*/} +
+
+ ); + })} +
+
+ ); +} + +export default ModelBlock; diff --git a/react-ui/src/pages/Home/components/NavBar/index.less b/react-ui/src/pages/Home/components/NavBar/index.less new file mode 100644 index 00000000..6641b46a --- /dev/null +++ b/react-ui/src/pages/Home/components/NavBar/index.less @@ -0,0 +1,52 @@ +.nav-bar { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 4.375rem; + color: white; + font-size: 0.9375rem; + + &__app-logo { + width: 1.75rem; + margin-right: 0.625rem; + } + + &__app-name { + margin-right: 6.625rem; + font-size: 1.375rem; + font-family: WenYiHei; + line-height: 2.2rem; + } + + &__menu-item { + margin-right: 3.125rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.06); + } + + &:last-of-type { + margin-right: 0; + } + } + + :global { + .ant-dropdown-trigger { + height: 2.15rem; + + .ant-avatar { + width: 1.875rem; + height: 1.875rem; + margin-right: 0 !important; + } + } + + .ant-dropdown-trigger > .anticon { + display: none; + } + } +} diff --git a/react-ui/src/pages/Home/components/NavBar/index.tsx b/react-ui/src/pages/Home/components/NavBar/index.tsx new file mode 100644 index 00000000..62a71c0b --- /dev/null +++ b/react-ui/src/pages/Home/components/NavBar/index.tsx @@ -0,0 +1,89 @@ +import { getAccessToken } from '@/access'; +import Avatar from '@/components/RightContent/AvatarDropdown'; +import { gotoPageIfLogin } from '@/utils/ui'; +import { useNavigate } from '@umijs/max'; +import { Flex } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; + +function NavBar() { + const navigate = useNavigate(); + const token = getAccessToken(); + + const gotoPage = (page: string) => { + if (page === 'login') { + navigate('/user/login'); + return; + } + + let pathname = ''; + switch (page) { + case 'service': + pathname = '/dataset/modelDeployment'; + break; + + case 'model': + pathname = '/dataset/model'; + break; + + case 'dataset': + pathname = '/dataset/dataset'; + break; + + case 'mirror': + pathname = '/dataset/mirror'; + break; + + case 'codeConfig': + pathname = '/dataset/codeConfig'; + break; + + default: + break; + } + + if (pathname) { + gotoPageIfLogin(pathname); + } + }; + + return ( +
+ + + 智能材料科研平台 +
gotoPage('service')}> + 服务 +
+
gotoPage('model')}> + 模型 +
+
gotoPage('dataset')}> + 数据集 +
+
gotoPage('mirror')}> + 镜像 +
+
gotoPage('codeConfig')}> + 代码配置 +
+
+ + {token ? ( + + ) : ( +
gotoPage('login')} + > + 登录 +
+ )} +
+ ); +} + +export default NavBar; diff --git a/react-ui/src/pages/Home/components/Service/index.less b/react-ui/src/pages/Home/components/Service/index.less new file mode 100644 index 00000000..dd83e60b --- /dev/null +++ b/react-ui/src/pages/Home/components/Service/index.less @@ -0,0 +1,75 @@ +.service { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: 5.625rem @home-padding-x 6.25rem; + .backgroundFullImage(url(@/assets/img/home/service-bg.png)); + + &__item { + width: 25%; + padding: 1.25rem 1.25rem 1.5625rem; + background: #ffffff; + border-radius: 1.25rem; + box-shadow: 0rem 0rem 0.75rem rgba(33, 73, 212, 0.06); + cursor: pointer; + + &:hover { + box-shadow: 0rem 0rem 0.75rem rgba(33, 73, 212, 0.12); + } + + &__image-container { + width: 18.4375rem; + height: 10.6875rem; + margin-bottom: 0.75rem; + overflow: hidden; + border-radius: 1.875rem; + } + + &__image { + width: 100%; + height: 100%; + transform: scale(1); + transition: transform 0.3s linear; + } + + &:hover &__image { + transform: scale(1.1); + } + + &__title { + height: 2.625rem; + margin-bottom: 0.875rem; + color: @home-text-color; + font-size: 0.9375rem; + .multiLine(2); + } + + &:hover &__title { + color: @primary-color; + } + + &__user-avatar { + flex: none; + width: 1.3125rem; + height: 1.3125rem; + margin-right: 0.5rem; + } + + &__user { + margin-right: 0.5rem; + color: #191919; + font-size: 0.875rem; + .singleLine(); + } + + &__date { + flex: none; + margin-right: 0; + margin-left: auto; + color: @text-color-tertiary; + font-size: 0.8125rem; + } + } +} diff --git a/react-ui/src/pages/Home/components/Service/index.tsx b/react-ui/src/pages/Home/components/Service/index.tsx new file mode 100644 index 00000000..828d0635 --- /dev/null +++ b/react-ui/src/pages/Home/components/Service/index.tsx @@ -0,0 +1,93 @@ +import ServiceImg1 from '@/assets/img/home/service1.png'; +import ServiceImg2 from '@/assets/img/home/service2.png'; +import ServiceImg3 from '@/assets/img/home/service3.png'; +import ServiceImg4 from '@/assets/img/home/service4.png'; +import { type ServiceData } from '@/pages/ModelDeployment/types'; +import { getPublicServicesReq } from '@/services/home'; +import { formatDate } from '@/utils/date'; +import { to } from '@/utils/promise'; +import { gotoPageIfLogin } from '@/utils/ui'; +import { Flex } from 'antd'; +import { motion, type Variants } from 'motion/react'; +import { useEffect, useState } from 'react'; +import BlockTitle from '../BlockTitle'; +import styles from './index.less'; + +const serviceVariants: Variants = { + offscreen: { + y: -200, + opacity: 0, + transition: { + ease: 'linear', + duration: 0, + }, + }, + onscreen: { + y: 0, + opacity: 1, + transition: { + type: 'spring', + duration: 1, + }, + }, +}; + +function ServiceBlock() { + const [serviceData, setServiceData] = useState([]); + const images = [ServiceImg1, ServiceImg2, ServiceImg3, ServiceImg4]; + + useEffect(() => { + const getPublicServices = async () => { + const [res] = await to(getPublicServicesReq()); + if (res && res.data) { + const { content = [] } = res.data; + setServiceData(content.slice(0, 4)); + } + }; + + getPublicServices(); + }, []); + return ( +
+ gotoPageIfLogin('/dataset/modelDeployment')} + > + + {serviceData.map((item, index) => { + return ( + gotoPageIfLogin(`/dataset/modelDeployment/serviceInfo/${item.id}`)} + initial="offscreen" + whileInView="onscreen" + custom={index} + > +
+ +
+
{item.service_name}
+ + +
{item.create_by}
+
{formatDate(item.create_time)}
+
+
+ ); + })} +
+
+ ); +} + +export default ServiceBlock; diff --git a/react-ui/src/pages/Home/components/Statistics/index.less b/react-ui/src/pages/Home/components/Statistics/index.less new file mode 100644 index 00000000..8c6b3369 --- /dev/null +++ b/react-ui/src/pages/Home/components/Statistics/index.less @@ -0,0 +1,32 @@ +.statistics { + display: flex; + align-items: center; + justify-content: space-evenly; + width: 87.5rem; + padding: 2.125rem 0 1.625rem; + .backgroundFullImage(url(@/assets/img/home/statistics-bg.png)); + + &__item { + display: flex; + align-items: center; + width: 9rem; + + &__icon { + width: 3.75rem; + height: 3.75rem; + margin-right: 1rem; + } + + &__count { + color: @home-text-color; + font-size: 2.25rem; + font-family: YouSheBiaoTiHei; + line-height: 3rem; + } + + &__name { + color: @home-text-color-secondary; + font-size: 0.875rem; + } + } +} diff --git a/react-ui/src/pages/Home/components/Statistics/index.tsx b/react-ui/src/pages/Home/components/Statistics/index.tsx new file mode 100644 index 00000000..1357532a --- /dev/null +++ b/react-ui/src/pages/Home/components/Statistics/index.tsx @@ -0,0 +1,105 @@ +import CodeIcon from '@/assets/img/home/code.png'; +import DatasetIcon from '@/assets/img/home/dataset.png'; +import ImageIcon from '@/assets/img/home/image.png'; +import ModelIcon from '@/assets/img/home/model.png'; +import ServiceIcon from '@/assets/img/home/service.png'; +import { getAssetPublicCountReq } from '@/services/home'; +import { to } from '@/utils/promise'; +import { useEffect, useState } from 'react'; +import CountUp from 'react-countup'; +import styles from './index.less'; + +function StatisticsBlock() { + const [assetCounts, setAssetCounts] = useState< + { title: string; value: number | undefined; icon: string }[] + >([ + { + title: '数据集', + value: undefined, + icon: DatasetIcon, + }, + { + title: '模型', + value: undefined, + icon: ModelIcon, + }, + { + title: '镜像', + value: undefined, + icon: ImageIcon, + }, + { + title: '代码配置', + value: undefined, + icon: CodeIcon, + }, + { + title: '服务', + value: undefined, + icon: ServiceIcon, + }, + ]); + useEffect(() => { + const getAssetPublicCount = async () => { + const [res] = await to(getAssetPublicCountReq()); + if (res && res.data) { + const { dataset, image, model, codeConfig, service } = res.data; + const items = [ + { + title: '数据集', + value: dataset, + icon: DatasetIcon, + }, + { + title: '模型', + value: model, + icon: ModelIcon, + }, + { + title: '镜像', + value: image, + icon: ImageIcon, + }, + { + title: '代码配置', + value: codeConfig, + icon: CodeIcon, + }, + { + title: '服务', + value: service, + icon: ServiceIcon, + }, + ]; + setAssetCounts(items); + } + }; + + getAssetPublicCount(); + }, []); + + return ( +
+ {assetCounts.map((item) => { + return ( +
+ +
+
+ {item.value ? ( + + ) : ( + '--' + )} +
+ +
{item.title}
+
+
+ ); + })} +
+ ); +} + +export default StatisticsBlock; diff --git a/react-ui/src/pages/Home/components/ViewMore/index.less b/react-ui/src/pages/Home/components/ViewMore/index.less new file mode 100644 index 00000000..302cec87 --- /dev/null +++ b/react-ui/src/pages/Home/components/ViewMore/index.less @@ -0,0 +1,50 @@ +.view-more { + position: absolute; + right: 16.25rem; + display: flex; + align-items: center; + color: .addAlpha(@home-text-color, 0.7) []; + font-size: 0.9375rem; + cursor: pointer; + transition: color 0.3s; + &:hover { + color: @home-text-color; + } + + &__img-container { + position: relative; + width: 1.125rem; + height: 1.125rem; + margin-left: 0.75rem; + transition: width 0.3s ease-in-out; + } + + &:hover &__img-container { + width: 1.625rem; + } + + &__img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transition: opacity 0.3s ease-in-out; + + &--first { + opacity: 1; + } + + &--second { + opacity: 0; + } + } + + &:hover &__img--first { + opacity: 0; + } + + &:hover &__img--second { + opacity: 1; + } +} diff --git a/react-ui/src/pages/Home/components/ViewMore/index.tsx b/react-ui/src/pages/Home/components/ViewMore/index.tsx new file mode 100644 index 00000000..b85acafd --- /dev/null +++ b/react-ui/src/pages/Home/components/ViewMore/index.tsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; +import styles from './index.less'; + +type ViewMoreProps = { + /** 自定义类名 */ + className?: string; + /** 自定义样式 */ + style?: React.CSSProperties; + /** 自定义样式 */ + onClick?: () => void; +}; + +function ViewMore({ className, style, onClick }: ViewMoreProps) { + return ( +
+ 查看更多 +
+ + +
+
+ ); +} + +export default ViewMore; diff --git a/react-ui/src/pages/Home/index.less b/react-ui/src/pages/Home/index.less new file mode 100644 index 00000000..aed372c8 --- /dev/null +++ b/react-ui/src/pages/Home/index.less @@ -0,0 +1,20 @@ +.home { + padding-top: @home-info-height; + font-family: Alibaba; + + &__separator { + position: relative; + z-index: 10; + display: block; + width: 97.5rem; + height: 8.375rem; + margin: -10.75rem auto 0; + } + + &__dataset-mirror { + background-image: url(@/assets/img/home/dataset-bg.png); + background-repeat: no-repeat; + background-position: top 6.25rem left; + background-size: 100% 100%; + } +} diff --git a/react-ui/src/pages/Home/index.tsx b/react-ui/src/pages/Home/index.tsx new file mode 100644 index 00000000..4cfee84c --- /dev/null +++ b/react-ui/src/pages/Home/index.tsx @@ -0,0 +1,31 @@ +import CodeConfig from './components/CodeConfig'; +import DatasetBlock from './components/Dataset'; +import Footer from './components/Footer'; +import IntroBlock from './components/Intro'; +import MirrorBlock from './components/Mirror'; +import ModelBlock from './components/Model'; +import ServiceBlock from './components/Service'; +import styles from './index.less'; + +function Home() { + return ( +
+ + + + +
+ + +
+ +
+
+ ); +} + +export default Home; diff --git a/react-ui/src/pages/HyperParameter/Aim/index.tsx b/react-ui/src/pages/HyperParameter/Aim/index.tsx new file mode 100644 index 00000000..3a8d1d0d --- /dev/null +++ b/react-ui/src/pages/HyperParameter/Aim/index.tsx @@ -0,0 +1,12 @@ +/* + * @Author: 赵伟 + * @Date: 2025-03-31 16:38:59 + * @Description: 实验对比 Aim + */ + +import IframePage, { IframePageType } from '@/components/IFramePage'; + +function AimPage() { + return ; +} +export default AimPage; diff --git a/react-ui/src/pages/HyperParameter/Create/index.less b/react-ui/src/pages/HyperParameter/Create/index.less new file mode 100644 index 00000000..ae065195 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/Create/index.less @@ -0,0 +1,50 @@ +.create-hyper-parameter { + height: 100%; + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 30px 30px 10px; + overflow: auto; + color: @text-color; + font-size: @font-size-content; + background-color: white; + border-radius: 10px; + + :global { + .ant-input-number { + width: 100%; + } + + .ant-form-item { + margin-bottom: 20px; + } + + .image-url { + margin-top: -15px; + .ant-form-item-label > label::after { + content: ''; + } + } + + .ant-btn-variant-text:disabled { + color: @text-disabled-color; + } + + .ant-btn-variant-text { + color: #565658; + } + + .ant-btn.ant-btn-icon-only .anticon { + font-size: 20px; + } + + .anticon-question-circle { + margin-top: -12px; + margin-left: 1px !important; + color: @text-color-tertiary !important; + font-size: 12px !important; + } + } + } +} diff --git a/react-ui/src/pages/HyperParameter/Create/index.tsx b/react-ui/src/pages/HyperParameter/Create/index.tsx new file mode 100644 index 00000000..3276a94e --- /dev/null +++ b/react-ui/src/pages/HyperParameter/Create/index.tsx @@ -0,0 +1,167 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 创建实验 + */ +import PageTitle from '@/components/PageTitle'; +import { addRayReq, getRayInfoReq, updateRayReq } from '@/services/hyperParameter'; +import { safeInvoke } from '@/utils/functional'; +import { to } from '@/utils/promise'; +import { useLocation, useNavigate, useParams } from '@umijs/max'; +import { App, Button, Form } from 'antd'; +import { useEffect } from 'react'; +import BasicConfig from '../components/CreateForm/BasicConfig'; +import ExecuteConfig from '../components/CreateForm/ExecuteConfig'; +import { getReqParamName } from '../components/CreateForm/utils'; +import { FormData, HyperParameterData } from '../types'; +import styles from './index.less'; + +function CreateHyperParameter() { + const navigate = useNavigate(); + const [form] = Form.useForm(); + const { message } = App.useApp(); + const params = useParams(); + const id = safeInvoke(Number)(params.id); + const { pathname } = useLocation(); + const isCopy = pathname.includes('copy'); + + useEffect(() => { + // 获取服务详情 + const getHyperParameterInfo = async (id: number) => { + const [res] = await to(getRayInfoReq({ id })); + if (res && res.data) { + const info: HyperParameterData = res.data; + const { name: name_str, parameters, points_to_evaluate, ...rest } = info; + const name = isCopy ? `${name_str}-copy` : name_str; + if (parameters && Array.isArray(parameters)) { + parameters.forEach((item) => { + const paramName = getReqParamName(item.type); + item.range = item[paramName]; + item[paramName] = undefined; + }); + } + + const formData = { + ...rest, + name, + parameters, + points_to_evaluate: points_to_evaluate ?? [], + }; + + form.setFieldsValue(formData); + } + }; + + // 编辑,复制 + if (id && !Number.isNaN(id)) { + getHyperParameterInfo(id); + } + }, [id, form, isCopy]); + + // 创建、更新、复制实验 + const createExperiment = async (formData: FormData) => { + // 按后台接口要求,修改参数表单数据结构,将 "value" 参数改为 "bounds"/"values"/"value" + const formParameters = formData['parameters']; + + const parameters = formParameters.map((item) => { + const paramName = getReqParamName(item.type); + const range = item.range; + return { + ...item, + [paramName]: range, + range: undefined, + }; + }); + + // 根据后台要求,修改表单数据 + const object = { + ...formData, + parameters: parameters, + }; + + const params = + id && !isCopy + ? { + id: id, + ...object, + } + : object; + + const request = id && !isCopy ? updateRayReq : addRayReq; + const [res] = await to(request(params)); + if (res) { + message.success('操作成功'); + navigate(-1); + } + }; + + // 提交 + const handleSubmit = (values: FormData) => { + createExperiment(values); + }; + + // 取消 + const cancel = () => { + navigate(-1); + }; + + let buttonText = '新建'; + let title = '新建实验'; + if (id) { + if (isCopy) { + title = '复制实验'; + buttonText = '确定'; + } else { + title = '编辑实验'; + buttonText = '更新'; + } + } + + return ( +
+ +
+
+
+ + + + + + + + +
+
+
+ ); +} + +export default CreateHyperParameter; diff --git a/react-ui/src/pages/HyperParameter/Info/index.less b/react-ui/src/pages/HyperParameter/Info/index.less new file mode 100644 index 00000000..d0c2398a --- /dev/null +++ b/react-ui/src/pages/HyperParameter/Info/index.less @@ -0,0 +1,40 @@ +.hyper-parameter-info { + position: relative; + height: 100%; + &__tabs { + height: 50px; + padding-left: 25px; + background-image: url(@/assets/img/page-title-bg.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + } + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + } + + &__tips { + position: absolute; + top: 11px; + left: 256px; + padding: 3px 12px; + color: #565658; + font-size: @font-size-content; + background: .addAlpha(@primary-color, 0.09) []; + border-radius: 4px; + + &::before { + position: absolute; + top: 10px; + left: -6px; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-right: 6px solid .addAlpha(@primary-color, 0.09) []; + border-bottom: 4px solid transparent; + content: ''; + } + } +} diff --git a/react-ui/src/pages/HyperParameter/Info/index.tsx b/react-ui/src/pages/HyperParameter/Info/index.tsx new file mode 100644 index 00000000..c941bb05 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/Info/index.tsx @@ -0,0 +1,47 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 自动机器学习详情 + */ +import PageTitle from '@/components/PageTitle'; +import { getRayInfoReq } from '@/services/hyperParameter'; +import { safeInvoke } from '@/utils/functional'; +import { to } from '@/utils/promise'; +import { useParams } from '@umijs/max'; +import { useCallback, useEffect, useState } from 'react'; +import HyperParameterBasic from '../components/HyperParameterBasic'; +import { HyperParameterData } from '../types'; +import styles from './index.less'; + +function HyperparameterInfo() { + const params = useParams(); + const hyperparameterId = safeInvoke(Number)(params.id); + const [hyperparameterInfo, setHyperparameterInfo] = useState( + undefined, + ); + + // 获取详情 + const getHyperparameterInfo = useCallback(async () => { + const [res] = await to(getRayInfoReq({ id: hyperparameterId })); + if (res && res.data) { + setHyperparameterInfo(res.data); + } + }, [hyperparameterId]); + + useEffect(() => { + if (hyperparameterId) { + getHyperparameterInfo(); + } + }, [hyperparameterId, getHyperparameterInfo]); + + return ( +
+ +
+ +
+
+ ); +} + +export default HyperparameterInfo; diff --git a/react-ui/src/pages/HyperParameter/Instance/index.less b/react-ui/src/pages/HyperParameter/Instance/index.less new file mode 100644 index 00000000..9a2f8bfb --- /dev/null +++ b/react-ui/src/pages/HyperParameter/Instance/index.less @@ -0,0 +1,42 @@ +.hyper-parameter-instance { + height: 100%; + + &__tabs { + height: 100%; + :global { + .ant-tabs-nav-list { + width: 100%; + height: 50px; + padding-left: 15px; + background-image: url(@/assets/img/page-title-bg.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + } + + .ant-tabs-content-holder { + height: calc(100% - 50px); + .ant-tabs-content { + height: 100%; + .ant-tabs-tabpane { + height: 100%; + } + } + } + } + } + + &__basic { + height: calc(100% - 10px); + margin-top: 10px; + } + + &__log { + height: calc(100% - 10px); + margin-top: 10px; + padding: 8px calc(@content-padding - 8px) 20px; + overflow-y: visible; + background-color: white; + border-radius: 10px; + } +} diff --git a/react-ui/src/pages/HyperParameter/Instance/index.tsx b/react-ui/src/pages/HyperParameter/Instance/index.tsx new file mode 100644 index 00000000..3605315f --- /dev/null +++ b/react-ui/src/pages/HyperParameter/Instance/index.tsx @@ -0,0 +1,213 @@ +import KFIcon from '@/components/KFIcon'; +import { ExperimentStatus } from '@/enums'; +import { getRayInsReq } from '@/services/hyperParameter'; +import { NodeStatus } from '@/types'; +import { parseJsonText } from '@/utils'; +import { safeInvoke } from '@/utils/functional'; +import { to } from '@/utils/promise'; +import { useParams } from '@umijs/max'; +import { Tabs } from 'antd'; +import { useEffect, useRef, useState } from 'react'; +import ExperimentHistory from '../components/ExperimentHistory'; +import ExperimentLog from '../components/ExperimentLog'; +import ExperimentResult from '../components/ExperimentResult'; +import HyperParameterBasic from '../components/HyperParameterBasic'; +import { HyperParameterData, HyperParameterInstanceData } from '../types'; +import styles from './index.less'; + +enum TabKeys { + Params = 'params', + Log = 'log', + Result = 'result', + History = 'history', +} + +const NodePrefix = 'workflow'; + +function HyperParameterInstance() { + const [experimentInfo, setExperimentInfo] = useState(undefined); + const [instanceInfo, setInstanceInfo] = useState( + undefined, + ); + // 超参数寻优运行有3个节点,运行状态取工作流状态,而不是 auto-hpo 节点状态 + const [workflowStatus, setWorkflowStatus] = useState(undefined); + const [nodes, setNodes] = useState | undefined>(undefined); + const params = useParams(); + const instanceId = safeInvoke(Number)(params.id); + const evtSourceRef = useRef(null); + + useEffect(() => { + if (instanceId) { + getExperimentInsInfo(false); + } + return () => { + closeSSE(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instanceId]); + + // 获取实验实例详情 + const getExperimentInsInfo = async (isStatusDetermined: boolean) => { + const [res] = await to(getRayInsReq(instanceId)); + if (res && res.data) { + const info = res.data as HyperParameterInstanceData; + const { param, node_status, argo_ins_name, argo_ins_ns, status } = info; + // 解析配置参数 + const paramJson = parseJsonText(param).data; + if (paramJson) { + // 实例详情返回的参数是字符串,需要转换 + if (typeof paramJson.parameters === 'string') { + paramJson.parameters = parseJsonText(paramJson.parameters); + } + if (!Array.isArray(paramJson.parameters)) { + paramJson.parameters = []; + } + + // 实例详情返回的运行参数是字符串,需要转换 + if (typeof paramJson.points_to_evaluate === 'string') { + paramJson.points_to_evaluate = parseJsonText(paramJson.points_to_evaluate); + } + if (!Array.isArray(paramJson.points_to_evaluate)) { + paramJson.points_to_evaluate = []; + } + setExperimentInfo({ + ...paramJson, + }); + } + + setInstanceInfo(info); + + // 这个接口返回的状态有延时,SSE 返回的状态是最新的 + // SSE 调用时,不需要解析 node_status,也不要重新建立 SSE + if (isStatusDetermined) { + return; + } + + // 进行节点状态 + const nodeStatusJson = parseJsonText(node_status); + if (nodeStatusJson) { + setNodes(nodeStatusJson); + Object.keys(nodeStatusJson).some((key) => { + if (key.startsWith(NodePrefix)) { + const workflowStatus = nodeStatusJson[key]; + setWorkflowStatus(workflowStatus); + return true; + } + return false; + }); + } + + // 运行中或者等待中,开启 SSE + if (status === ExperimentStatus.Pending || status === ExperimentStatus.Running) { + setupSSE(argo_ins_name, argo_ins_ns); + } + } + }; + + const setupSSE = (name: string, namespace: string) => { + const { origin } = location; + const params = encodeURIComponent(`metadata.namespace=${namespace},metadata.name=${name}`); + const evtSource = new EventSource( + `${origin}/api/v1/realtimeStatus?listOptions.fieldSelector=${params}`, + { withCredentials: false }, + ); + evtSource.onmessage = (event) => { + const data = event?.data; + if (!data) { + return; + } + const dataJson = parseJsonText(data); + if (dataJson) { + const nodes = dataJson?.result?.object?.status?.nodes; + if (nodes) { + // 设置节点 + setNodes(nodes); + + // 设置总 workflow 状态 + const workflowStatus = Object.values(nodes).find((node: any) => + node.displayName.startsWith(NodePrefix), + ) as NodeStatus; + if (workflowStatus) { + setWorkflowStatus(workflowStatus); + + // 实验结束,关闭 SSE,获取实验实例结果 + if ( + workflowStatus.phase !== ExperimentStatus.Pending && + workflowStatus.phase !== ExperimentStatus.Running + ) { + closeSSE(); + getExperimentInsInfo(true); + } + } + } + } + }; + evtSource.onerror = (error) => { + console.error('SSE error: ', error); + }; + + evtSourceRef.current = evtSource; + }; + + const closeSSE = () => { + if (evtSourceRef.current) { + evtSourceRef.current.close(); + evtSourceRef.current = null; + } + }; + + const basicTabItems = [ + { + key: TabKeys.Params, + label: '基本信息', + icon: , + children: ( + + ), + }, + { + key: TabKeys.Log, + label: '日志', + icon: , + children: ( +
+ {instanceInfo && nodes && } +
+ ), + }, + ]; + + const resultTabItems = [ + { + key: TabKeys.Result, + label: '实验结果', + icon: , + children: , + }, + { + key: TabKeys.History, + label: '寻优列表', + icon: , + children: , + }, + ]; + + const tabItems = + instanceInfo?.status === ExperimentStatus.Succeeded + ? [...basicTabItems, ...resultTabItems] + : basicTabItems; + + return ( +
+ +
+ ); +} + +export default HyperParameterInstance; diff --git a/react-ui/src/pages/HyperParameter/List/index.tsx b/react-ui/src/pages/HyperParameter/List/index.tsx new file mode 100644 index 00000000..5ebfcde9 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/List/index.tsx @@ -0,0 +1,13 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 超参数自动寻优 + */ + +import ExperimentList, { ExperimentListType } from '@/pages/AutoML/components/ExperimentList'; + +function HyperParameter() { + return ; +} + +export default HyperParameter; diff --git a/react-ui/src/pages/HyperParameter/components/CreateForm/BasicConfig.tsx b/react-ui/src/pages/HyperParameter/components/CreateForm/BasicConfig.tsx new file mode 100644 index 00000000..9d06fd10 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/CreateForm/BasicConfig.tsx @@ -0,0 +1,58 @@ +import SubAreaTitle from '@/components/SubAreaTitle'; +import { Col, Form, Input, Row } from 'antd'; + +function BasicConfig() { + return ( + <> + + +
+ + + + + + + + + + + + + + ); +} + +export default BasicConfig; diff --git a/react-ui/src/pages/HyperParameter/components/CreateForm/ExecuteConfig.tsx b/react-ui/src/pages/HyperParameter/components/CreateForm/ExecuteConfig.tsx new file mode 100644 index 00000000..313f8179 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/CreateForm/ExecuteConfig.tsx @@ -0,0 +1,612 @@ +import CodeSelect from '@/components/CodeSelect'; +import KFIcon from '@/components/KFIcon'; +import ParameterSelect from '@/components/ParameterSelect'; +import ResourceSelect, { + ResourceSelectorType, + requiredValidator, +} from '@/components/ResourceSelect'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { hyperParameterOptimizedModeOptions } from '@/enums'; +import { isEmpty } from '@/utils'; +import { modalConfirm, removeFormListItem } from '@/utils/ui'; +import { MinusCircleOutlined, PlusCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons'; +import { + Button, + Col, + Flex, + Form, + Input, + InputNumber, + Radio, + Row, + Select, + Tooltip, + Typography, +} from 'antd'; +import { isEqual } from 'lodash'; +import PopParameterRange from './PopParameterRange'; +import styles from './index.less'; +import { + axParameterOptions, + parameterOptions, + schedulerAlgorithms, + searchAlgorithms, + type FormParameter, +} from './utils'; + +const parameterTooltip = `uniform(low, high) + 在 low 和 high 之间均匀采样浮点数 + + quniform(low, high, q) + 在 low 和 high 之间均匀采样浮点数,四舍五入到 q 的倍数 + + loguniform(low, high) + 在 low 和 high 之间均匀采样浮点数,对数空间采样 + + qloguniform(low, high, q) + 在 low 和 high 之间均匀采样浮点数,对数空间采样并四舍五入到 q 的倍数 + + randn(m, s) + 在均值为 m,方差为 s 的正态分布中进行随机浮点数抽样 + + qrandn(m, s, q) + 在均值为 m,方差为 s 的正态分布中进行随机浮点数抽样,四舍五入到 q 的倍数 + + randint(low, high) + 在 low(包括)到 high(不包括)之间均匀采样整数 + + qrandint(low, high, q) + 在 low(包括)到 high(不包括)之间均匀采样整数,四舍五入到 q 的倍数(包括 high) + + lograndint(low, high) + 在 low(包括)到 high(不包括)之间对数空间上均匀采样整数 + + qlograndint(low, high, q) + 在 low(包括)到 high(不包括)之间对数空间上均匀采样整数,并四舍五入到 q 的倍数 + + choice + 从指定的选项中采样一个选项 + + grid + 对选项进行网格搜索,每个值都将被采样 +`; + +const axParameterTooltip = `fixed + 固定取值 + + range(low, high) + 在 low 和 high 范围内采样取值 + + choice + 从指定的选项中采样一个选项 + `; + +function ExecuteConfig() { + const form = Form.useFormInstance(); + const searchAlgorithm = Form.useWatch('search_alg', form); + const paramsTypeOptions = searchAlgorithm === 'Ax' ? axParameterOptions : parameterOptions; + const paramsTypeTooltip = searchAlgorithm === 'Ax' ? axParameterTooltip : parameterTooltip; + + const handleSearchAlgorithmChange = (value: string) => { + if ( + (value === 'Ax' && searchAlgorithm !== 'Ax') || + (value !== 'Ax' && searchAlgorithm === 'Ax') + ) { + form.setFieldValue('parameters', [{ name: '' }]); + } + }; + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {({ getFieldValue }) => { + const schedulerAlgorithm = getFieldValue('scheduler'); + if (schedulerAlgorithm === 'ASHA' || schedulerAlgorithm === 'HyperBand') { + return ( + + + + + + + + ); + } else if (schedulerAlgorithm === 'MedianStopping') { + return ( + + + + + + + + ); + } + return null; + }} + + + + {(fields, { add, remove }) => ( + <> + + + + + +
+ +
参数名称
+
+ 参数类型 + + + +
+
取值范围
+
操作
+
+ + {fields.map(({ key, name, ...restField }, index) => ( + + ['parameters', i, 'name'])} + required + rules={[ + { + validator: (_, value) => { + if (!value) { + return Promise.reject(new Error('请输入参数名称')); + } + // 判断不能重名 + const list = form + .getFieldValue('parameters') + .filter( + (item: FormParameter | undefined) => + item !== undefined && item !== null, + ); + + const names = list.map((item: FormParameter) => item.name); + if (new Set(names).size !== names.length) { + return Promise.reject(new Error('名称不能重复')); + } + return Promise.resolve(); + }, + }, + ]} + > + + + + form.validateFields(['points_to_evaluate'])} + /> + + ))} +
+
+ + {index === fields.length - 1 && ( + + )} +
+ + ))} + {fields.length === 0 && ( + + + + )} + + + + )} + + ); + }} + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default ExecuteConfig; diff --git a/react-ui/src/pages/HyperParameter/components/CreateForm/ParameterRange/index.less b/react-ui/src/pages/HyperParameter/components/CreateForm/ParameterRange/index.less new file mode 100644 index 00000000..25edd4bc --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/CreateForm/ParameterRange/index.less @@ -0,0 +1,47 @@ +.parameter-range { + width: 360px; + &__type { + margin-bottom: 10px; + color: @text-color-secondary; + font-size: @font-size-input; + &::before { + display: inline-block; + color: @error-color; + font-size: 14px; + font-family: SimSun, sans-serif; + line-height: 1; + content: '*'; + margin-inline-end: 4px; + } + } + &__desc { + margin-bottom: 15px; + padding: 4px 8px; + color: @text-color-tertiary; + font-size: 13px; + background: rgba(62, 96, 163, 0.05); + border-radius: 6px; + } + &__form { + width: 100%; + &__list { + width: 100%; + max-height: 300px; + overflow-x: visible; + overflow-y: auto; + } + &__space { + flex: none; + width: 22px; + color: @text-color-tertiary; + font-size: @font-size-input; + line-height: 32px; + text-align: center; + } + &__button { + width: 100%; + margin-bottom: 0; + text-align: center; + } + } +} diff --git a/react-ui/src/pages/HyperParameter/components/CreateForm/ParameterRange/index.tsx b/react-ui/src/pages/HyperParameter/components/CreateForm/ParameterRange/index.tsx new file mode 100644 index 00000000..d33fcf13 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/CreateForm/ParameterRange/index.tsx @@ -0,0 +1,152 @@ +import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons'; +import { Button, Flex, Form, Input, InputNumber } from 'antd'; +import React from 'react'; +import { ParameterType, getFormOptions, parameterTooltip } from '../utils'; +import styles from './index.less'; + +type ParameterRangeProps = { + type: ParameterType; + value?: any[]; + onCancel?: () => void; + onConfirm?: (value: any[]) => void; +}; + +function ParameterRange({ type, value, onConfirm }: ParameterRangeProps) { + const [form] = Form.useForm(); + const isList = type === ParameterType.Choice || type === ParameterType.Grid; + const formOptions = getFormOptions(type, value); + + const initialValues = isList + ? { list: value && value.length > 0 ? value.map((item) => ({ value: item })) : [{ value: '' }] } + : formOptions.reduce((prev, item) => { + prev[item.name] = item.value; + return prev; + }, {} as Record); + + const handleFinish = (values: any) => { + if (type === ParameterType.Choice || type === ParameterType.Grid) { + const array = values.list.map((item: any) => item.value); + onConfirm?.(array); + } else { + const numbers = Object.values(values).map((item: any) => Number(item)); + onConfirm?.(numbers); + } + }; + + return ( +
+
{type}
+
{parameterTooltip[type]}
+
+ {isList ? ( +
+ + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }, index) => ( + + + + + + + {index === fields.length - 1 && ( + + )} + + + ))} + {fields.length === 0 && ( + + + + )} + + )} + +
+ ) : ( + + {formOptions.map((item, index) => { + return ( + + + + + {index !== formOptions.length - 1 && ( + + {index === 0 ? '-' : ' '} + + )} + + ); + })} + + )} + + + + +
+ ); +} + +export default ParameterRange; diff --git a/react-ui/src/pages/HyperParameter/components/CreateForm/PopParameterRange/index.less b/react-ui/src/pages/HyperParameter/components/CreateForm/PopParameterRange/index.less new file mode 100644 index 00000000..92080c3e --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/CreateForm/PopParameterRange/index.less @@ -0,0 +1,83 @@ +.parameter-range { + border-radius: 18px; + box-shadow: 0px 3px 10px rgba(22, 100, 255, 0.15); + :global { + .ant-popover-content { + .ant-popover-inner { + width: 400px; + padding: 20px 20px 12px; + background-image: url(@/assets/img/popover-bg.png); + background-repeat: no-repeat; + background-position: top left; + background-size: 100% auto; + } + .ant-popconfirm-description { + margin-top: 20px; + } + + .ant-popconfirm-buttons { + display: none; + } + } + } + + &__input { + display: flex; + align-items: center; + width: 100%; + min-height: 46px; + padding: 10px 11px; + color: @text-color; + font-size: @font-size-input-lg; + line-height: 1.5; + background-color: white; + border: 1px solid #d9d9d9; + border-radius: 8px; + cursor: pointer; + + &__text { + flex: 1; + margin-right: 10px; + } + + &__icon { + flex: none; + } + + &:hover { + border-color: #4086ff; + } + + &:hover &__icon { + color: #4086ff; + } + + &&--disabled { + background-color: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.04) !important; + cursor: not-allowed; + } + + &&--empty { + color: @text-placeholder-color; + } + + &&--disabled &__icon { + color: #aaaaaa !important; + } + } +} + +.parameter-range-title { + color: @text-color; + font-weight: 500; + font-size: @font-size-content; +} + +.parameter-range-title-icon { + color: @text-color-secondary; + + &:hover { + color: @text-color; + } +} diff --git a/react-ui/src/pages/HyperParameter/components/CreateForm/PopParameterRange/index.tsx b/react-ui/src/pages/HyperParameter/components/CreateForm/PopParameterRange/index.tsx new file mode 100644 index 00000000..b5db36b9 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/CreateForm/PopParameterRange/index.tsx @@ -0,0 +1,105 @@ +import KFIcon from '@/components/KFIcon'; +import { isEmpty } from '@/utils'; +import { Flex, Popconfirm, Typography } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useRef, useState } from 'react'; +import ParameterRange from '../ParameterRange'; +import { ParameterType } from '../utils'; +import styles from './index.less'; + +type ParameterRangeProps = { + type: ParameterType; + value?: any[]; + onChange?: (value: any[]) => void; +}; + +function PopParameterRange({ type, value, onChange }: ParameterRangeProps) { + const [open, setOpen] = useState(false); + const popconfirmRef = useRef(null); + const disabled = !type; + const jsonText = JSON.stringify(value); + + const handleClickOutside = (event: MouseEvent) => { + // 判断点击是否在 Popconfirm 内 + const popconfirmNode = document.getElementById('pop-parameter'); + if (popconfirmNode && !popconfirmNode.contains(event.target as Node)) { + setOpen(false); + } + }; + + useEffect(() => { + if (open) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + // 清理事件监听器 + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [open]); + + const handleClick = () => { + if (!disabled) { + setOpen(true); + } + }; + + const handleCancel = () => { + setOpen(false); + }; + + const handleConfirm = (value: number[]) => { + onChange?.(value); + setOpen(false); + }; + + return ( +
+ } + disabled={disabled} + description={ + + } + overlayClassName={styles['parameter-range']} + icon={null} + open={open} + destroyTooltipOnHide + > +
+ + {jsonText ?? '请选择'} + + +
+
+
+ ); +} + +function PopconfirmTitle({ title, onClose }: { title: string; onClose: () => void }) { + return ( + + {title} + + + ); +} + +export default PopParameterRange; diff --git a/react-ui/src/pages/HyperParameter/components/CreateForm/index.less b/react-ui/src/pages/HyperParameter/components/CreateForm/index.less new file mode 100644 index 00000000..bd264d3c --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/CreateForm/index.less @@ -0,0 +1,146 @@ +.metrics-weight { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } +} + +.add-weight { + margin-bottom: 0 !important; + + // 增加样式权重 + & &__button { + width: calc(100% - 126px); + border-color: .addAlpha(@primary-color, 0.5) []; + box-shadow: none !important; + &:hover { + border-style: solid; + } + } +} + +.hyper-parameter { + width: 83.33%; + margin-bottom: 20px; + border: 1px solid rgba(234, 234, 234, 0.8); + border-radius: 4px; + &__header { + height: 50px; + padding-left: 8px; + color: @text-color; + font-size: @font-size; + background: #f8f8f9; + border-radius: 4px 4px 0px 0px; + + &__name, + &__type, + &__space { + flex: 1; + min-width: 0; + margin-right: 15px; + + &::before { + display: inline-block; + color: @error-color; + font-size: 14px; + font-family: SimSun, sans-serif; + line-height: 1; + content: '*'; + margin-inline-end: 4px; + } + + :global { + .anticon-question-circle { + vertical-align: middle; + cursor: help; + } + } + } + + &__tooltip { + max-width: 600px; + :global { + .ant-tooltip-inner { + max-height: 400px; + overflow-y: auto; + white-space: pre-line; + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.5); + } + } + } + } + + &__operation { + flex: none; + width: 100px; + } + } + &__body { + padding: 8px; + border-bottom: 1px solid rgba(234, 234, 234, 0.8); + + &:last-child { + border-bottom: none; + } + + &__name, + &__type, + &__space { + flex: 1; + min-width: 0; + margin-right: 15px; + margin-bottom: 0 !important; + } + + &__operation { + display: flex; + flex: none; + align-items: center; + width: 100px; + height: 46px; + } + } + + &__add { + display: flex; + align-items: center; + justify-content: center; + padding: 15px 0; + } +} + +.run-parameter { + width: calc(41.66% + 126px); + margin-bottom: 20px; + + &__body { + flex: 1; + margin-right: 10px; + padding: 20px 20px 0; + border: 1px dashed #e0e0e0; + border-radius: 8px; + + :global { + .ant-form-item-label { + label { + width: calc(100% - 10px); + } + } + } + } + + &__operation { + display: flex; + flex: none; + align-items: center; + width: 100px; + } + + &__error { + margin-top: -20px; + color: @error-color; + } +} diff --git a/react-ui/src/pages/HyperParameter/components/CreateForm/utils.ts b/react-ui/src/pages/HyperParameter/components/CreateForm/utils.ts new file mode 100644 index 00000000..558637e9 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/CreateForm/utils.ts @@ -0,0 +1,228 @@ +export enum ParameterType { + Uniform = 'uniform', + QUniform = 'quniform', + LogUniform = 'loguniform', + QLogUniform = 'qloguniform', + Randn = 'randn', + QRandn = 'qrandn', + RandInt = 'randint', + QRandInt = 'qrandint', + LogRandInt = 'lograndint', + QLogRandInt = 'qlograndint', + Choice = 'choice', + Grid = 'grid', + Range = 'range', + Fixed = 'fixed', +} + +export const parameterOptions = [ + 'uniform', + 'quniform', + 'loguniform', + 'qloguniform', + 'randn', + 'qrandn', + 'randint', + 'qrandint', + 'lograndint', + 'qlograndint', + 'choice', + 'grid', +].map((name) => ({ + label: name, + value: name, +})); + +export const axParameterOptions = ['fixed', 'range', 'choice'].map((name) => ({ + label: name, + value: name, +})); + +export const parameterTooltip: Record = { + [ParameterType.Uniform]: '在 low 和 high 之间均匀采样浮点数', + [ParameterType.QUniform]: '在 low 和 high 之间均匀采样浮点数,四舍五入到 q 的倍数', + [ParameterType.LogUniform]: '在 low 和 high 之间均匀采样浮点数,对数空间采样', + [ParameterType.QLogUniform]: + '在 low 和 high 之间均匀采样浮点数,对数空间采样并四舍五入到 q 的倍数', + [ParameterType.Randn]: '在均值为 m,方差为 s 的正态分布中进行随机浮点数抽样', + [ParameterType.QRandn]: + '在均值为 m,方差为 s 的正态分布中进行随机浮点数抽样,四舍五入到 q 的倍数', + [ParameterType.RandInt]: '在 low(包括)到 high(不包括)之间均匀采样整数', + [ParameterType.QRandInt]: + '在 low(包括)到 high(不包括)之间均匀采样整数,四舍五入到 q 的倍数(包括 high)', + [ParameterType.LogRandInt]: '在 low(包括)到 high(不包括)之间对数空间上均匀采样整数', + [ParameterType.QLogRandInt]: + '在 low(包括)到 high(不包括)之间对数空间上均匀采样整数,并四舍五入到 q 的倍数', + [ParameterType.Choice]: '从指定的选项中采样一个选项', + [ParameterType.Grid]: '对选项进行网格搜索,每个值都将被采样', + [ParameterType.Range]: '在 low 和 high 范围内采样取值', + [ParameterType.Fixed]: '固定取值', +}; + +export type ParameterData = { + label: string; + name: string; + value?: number; +}; + +// 参数表单数据 +export type FormParameter = { + name: string; // 参数名称 + type: ParameterType; // 参数类型 + range: any; // 参数值 + [key: string]: any; +}; + +export const getFormOptions = (type?: ParameterType, value?: number[]): ParameterData[] => { + const numbers = + value?.map((item) => { + const num = Number(item); + if (isNaN(num)) { + return undefined; + } + return num; + }) ?? []; + switch (type) { + case ParameterType.Uniform: + case ParameterType.LogUniform: + case ParameterType.RandInt: + case ParameterType.LogRandInt: + case ParameterType.Range: + return [ + { + name: 'low', + label: '最小值', + value: numbers?.[0], + }, + { + name: 'high', + label: '最大值', + value: numbers?.[1], + }, + ]; + case ParameterType.QUniform: + case ParameterType.QLogUniform: + case ParameterType.QRandInt: + case ParameterType.QLogRandInt: + return [ + { + name: 'low', + label: '最小值', + value: numbers?.[0], + }, + { + name: 'high', + label: '最大值', + value: numbers?.[1], + }, + { + name: 'q', + label: '间隔', + value: numbers?.[2], + }, + ]; + case ParameterType.Randn: + return [ + { + name: 'm', + label: '均值', + value: numbers?.[0], + }, + { + name: 's', + label: '方差', + value: numbers?.[1], + }, + ]; + case ParameterType.QRandn: + return [ + { + name: 'm', + label: '均值', + value: numbers?.[0], + }, + { + name: 's', + label: '方差', + value: numbers?.[1], + }, + { + name: 'q', + label: '间隔', + value: numbers?.[2], + }, + ]; + case ParameterType.Fixed: + return [ + { + name: 'value', + label: '值', + value: numbers?.[0], + }, + ]; + default: + return []; + } +}; + +export const getReqParamName = (type: ParameterType) => { + if (type === ParameterType.Fixed) { + return 'value'; + } else if (type === ParameterType.Choice || type === ParameterType.Grid) { + return 'values'; + } else { + return 'bounds'; + } +}; + +// 搜索算法 +export const searchAlgorithms = [ + { + label: 'HyperOpt(分布式异步超参数优化)', + value: 'HyperOpt', + }, + { + label: 'HEBO(异方差进化贝叶斯优化)', + value: 'HEBO', + }, + { + label: 'BayesOpt(贝叶斯优化)', + value: 'BayesOpt', + }, + { + label: 'Optuna', + value: 'Optuna', + }, + { + label: 'ZOOpt', + value: 'ZOOpt', + }, + { + label: 'Ax', + value: 'Ax', + }, +]; + +// 调度算法 +export const schedulerAlgorithms = [ + { + label: 'ASHA(异步连续减半)', + value: 'ASHA', + }, + { + label: 'HyperBand(HyperBand 早停算法)', + value: 'HyperBand', + }, + { + label: 'MedianStopping(中值停止规则)', + value: 'MedianStopping', + }, + { + label: 'PopulationBased(基于种群训练)', + value: 'PopulationBased', + }, + { + label: 'PB2(Population Based Bandits)', + value: 'PB2', + }, +]; diff --git a/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.less b/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.less new file mode 100644 index 00000000..761c2c9b --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.less @@ -0,0 +1,58 @@ +.experiment-history { + height: calc(100% - 10px); + margin-top: 10px; + &__content { + height: 100%; + padding: 20px @content-padding; + background-color: white; + border-radius: 10px; + + &__table { + height: calc(100% - 52px); + margin-top: 20px; + } + + :global { + .ant-table-container { + border: none !important; + } + .ant-table-thead { + .ant-table-cell { + background-color: rgb(247, 247, 247); + border-color: @border-color !important; + } + } + .ant-table-tbody { + .ant-table-cell { + border-right: none !important; + border-left: none !important; + } + } + .ant-table-tbody-virtual::after { + border-bottom: none !important; + } + } + } +} + +.cell-index { + position: relative; + width: 100%; + white-space: nowrap; + + &__best-tag { + margin-left: 8px; + padding: 1px 10px; + color: @success-color; + font-weight: normal; + font-size: 13px; + white-space: nowrap; + background-color: .addAlpha(@success-color, 0.1) []; + border-radius: 2px; + } +} + +.table-best-row { + color: @success-color; + font-weight: bold; +} diff --git a/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx b/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx new file mode 100644 index 00000000..39ecebda --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/ExperimentHistory/index.tsx @@ -0,0 +1,206 @@ +import TableColTitle from '@/components/TableColTitle'; +import TrialStatusCell from '@/pages/HyperParameter/components/TrialStatusCell'; +import { HyperParameterTrial } from '@/pages/HyperParameter/types'; +import { getExpMetricsReq } from '@/services/hyperParameter'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { useNavigate } from '@umijs/max'; +import { App, Button, Table, type TableProps } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; +import TrialFileTree from '../TrialFileTree'; +import styles from './index.less'; + +type ExperimentHistoryProps = { + trialList?: HyperParameterTrial[]; +}; + +function ExperimentHistory({ trialList = [] }: ExperimentHistoryProps) { + const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const { message } = App.useApp(); + const [tableData, setTableData] = useState([]); + const [loading, setLoading] = useState(false); + + // 防止 Tabs 卡顿 + useEffect(() => { + setLoading(true); + setTimeout(() => { + setTableData(trialList); + setLoading(false); + }, 500); + }, [trialList]); + + // 计算 column + const first: HyperParameterTrial | undefined = trialList ? trialList[0] : undefined; + const config: Record = first?.config ?? {}; + const metricAnalysis: Record = first?.metric_analysis ?? {}; + const paramsNames = Object.keys(config); + const metricNames = Object.keys(metricAnalysis); + const navigate = useNavigate(); + + const trialColumns: TableProps['columns'] = [ + { + title: '序号', + dataIndex: 'index', + key: 'index', + width: 100, + fixed: 'left', + render: (_text, record, index: number) => { + return ( +
+ {index + 1} + {record.is_best && 最佳} +
+ ); + }, + }, + { + title: '基本信息', + align: 'center', + children: [ + { + title: '运行次数', + dataIndex: 'training_iteration', + key: 'training_iteration', + width: 120, + fixed: 'left', + render: tableCellRender(false), + }, + { + title: '平均时长(秒)', + dataIndex: 'time_avg', + key: 'time_avg', + width: 150, + fixed: 'left', + render: tableCellRender(false, TableCellValueType.Custom, { + format: (value = 0) => Number(value).toFixed(2), + }), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 120, + fixed: 'left', + render: TrialStatusCell, + }, + ], + }, + ]; + + if (paramsNames.length) { + trialColumns.push({ + title: '运行参数', + dataIndex: 'config', + key: 'config', + align: 'center', + children: paramsNames.map((name) => ({ + title: , + dataIndex: ['config', name], + key: name, + width: 120, + align: 'center', + render: tableCellRender(true), + })), + }); + } + + if (metricNames.length) { + trialColumns.push({ + title: `指标分析(${first?.metric ?? ''})`, + dataIndex: 'metrics', + key: 'metrics', + align: 'center', + children: metricNames.map((name) => ({ + title: , + dataIndex: ['metric_analysis', name], + key: name, + width: 120, + align: 'center', + render: tableCellRender(true), + })), + }); + } + + // 自定义展开视图 + const expandedRowRender = (record: HyperParameterTrial) => { + return ; + }; + + // 展开实例 + const handleExpandChange = (expanded: boolean, record: HyperParameterTrial) => { + if (expanded) { + setExpandedRowKeys([record.trial_id]); + } else { + setExpandedRowKeys([]); + } + }; + + // 选择行 + const rowSelection: TableProps['rowSelection'] = { + type: 'checkbox', + columnWidth: 48, + fixed: 'left', + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys); + }, + }; + + // 对比 + const handleComparisonClick = () => { + if (selectedRowKeys.length < 1) { + message.error('请至少选择一项'); + return; + } + getExpMetrics(); + }; + + // 获取对比 url + const getExpMetrics = async () => { + const [res] = await to(getExpMetricsReq(selectedRowKeys)); + if (res && res.data) { + const url = res.data; + SessionStorage.setItem(SessionStorage.aimUrlKey, url); + navigate('compare-visual'); + } + }; + + return ( +
+
+ +
+
(record.is_best ? styles['table-best-row'] : '')} + dataSource={tableData} + columns={trialColumns} + pagination={false} + bordered={true} + scroll={{ y: 'calc(100% - 110px)', x: '100%' }} + rowKey="trial_id" + expandable={{ + expandedRowRender: expandedRowRender, + onExpand: handleExpandChange, + expandedRowKeys: expandedRowKeys, + rowExpandable: (record: HyperParameterTrial) => !!record.file, + }} + rowSelection={rowSelection} + /> + + + + ); +} + +export default ExperimentHistory; diff --git a/react-ui/src/pages/HyperParameter/components/ExperimentLog/index.less b/react-ui/src/pages/HyperParameter/components/ExperimentLog/index.less new file mode 100644 index 00000000..6eb6f074 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/ExperimentLog/index.less @@ -0,0 +1,16 @@ +.experiment-log { + height: 100%; + &__tabs { + height: 100%; + :global { + .ant-tabs-nav-list { + padding-left: 0 !important; + background: none !important; + } + } + + &__log { + height: 100%; + } + } +} diff --git a/react-ui/src/pages/HyperParameter/components/ExperimentLog/index.tsx b/react-ui/src/pages/HyperParameter/components/ExperimentLog/index.tsx new file mode 100644 index 00000000..6237adf0 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/ExperimentLog/index.tsx @@ -0,0 +1,113 @@ +import { ExperimentStatus } from '@/enums'; +import EmptyLog from '@/pages/AutoML/components/ExperimentLog/empty'; +import LogList from '@/pages/Experiment/components/LogList'; +import { HyperParameterInstanceData } from '@/pages/HyperParameter/types'; +import { NodeStatus } from '@/types'; +import { Tabs } from 'antd'; +import styles from './index.less'; + +const NodePrefix = 'auto-hpo'; + +type ExperimentLogProps = { + instanceInfo: HyperParameterInstanceData; + nodes: Record; +}; + +function ExperimentLog({ instanceInfo, nodes }: ExperimentLogProps) { + let hpoNodeStatus: NodeStatus | undefined; + let frameworkCloneNodeStatus: NodeStatus | undefined; + let trainCloneNodeStatus: NodeStatus | undefined; + + Object.keys(nodes) + .sort((key1, key2) => { + const node1 = nodes[key1]; + const node2 = nodes[key2]; + return new Date(node1.startedAt).getTime() - new Date(node2.startedAt).getTime(); + }) + .forEach((key) => { + const node = nodes[key]; + if (node.displayName.startsWith(NodePrefix)) { + hpoNodeStatus = node; + } else if (node.displayName.startsWith('git-clone') && !frameworkCloneNodeStatus) { + frameworkCloneNodeStatus = node; + } else if ( + node.displayName.startsWith('git-clone') && + frameworkCloneNodeStatus && + node.displayName !== frameworkCloneNodeStatus?.displayName + ) { + trainCloneNodeStatus = node; + } + }); + + const tabItems = [ + // { + // key: 'git-clone-framework', + // label: '框架代码日志', + // // icon: , + // children: ( + //
+ // {frameworkCloneNodeStatus && ( + // + // )} + //
+ // ), + // }, + { + key: 'git-clone-train', + label: '系统日志', + // icon: , + children: ( +
+ {trainCloneNodeStatus ? ( + + ) : ( + + )} +
+ ), + }, + { + key: 'auto-hpo', + label: '超参寻优日志', + // icon: , + children: ( +
+ {hpoNodeStatus ? ( + + ) : ( + + )} +
+ ), + }, + ]; + + return ( +
+ +
+ ); +} + +export default ExperimentLog; diff --git a/react-ui/src/pages/HyperParameter/components/ExperimentResult/index.less b/react-ui/src/pages/HyperParameter/components/ExperimentResult/index.less new file mode 100644 index 00000000..239a3abf --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/ExperimentResult/index.less @@ -0,0 +1,17 @@ +.experiment-result { + height: calc(100% - 10px); + margin-top: 10px; + padding: 20px @content-padding; + overflow-y: auto; + background-color: white; + border-radius: 10px; + + &__table { + height: 400px; + } + + &__text { + font-family: 'Roboto Mono', 'Menlo', 'Consolas', 'Monaco', monospace; + white-space: pre; + } +} diff --git a/react-ui/src/pages/HyperParameter/components/ExperimentResult/index.tsx b/react-ui/src/pages/HyperParameter/components/ExperimentResult/index.tsx new file mode 100644 index 00000000..4a7d3dcc --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/ExperimentResult/index.tsx @@ -0,0 +1,37 @@ +import InfoGroup from '@/components/InfoGroup'; +import { getFileReq } from '@/services/file'; +import { to } from '@/utils/promise'; +import { useEffect, useState } from 'react'; +import styles from './index.less'; + +type ExperimentResultProps = { + fileUrl?: string; +}; + +function ExperimentResult({ fileUrl }: ExperimentResultProps) { + const [result, setResult] = useState(''); + + useEffect(() => { + // 获取实验运行历史记录 + const getResultFile = async () => { + const [res] = await to(getFileReq(fileUrl)); + if (res) { + setResult(res as any as string); + } + }; + + if (fileUrl) { + getResultFile(); + } + }, [fileUrl]); + + return ( +
+ +
{result}
+
+
+ ); +} + +export default ExperimentResult; diff --git a/react-ui/src/pages/HyperParameter/components/HyperParameterBasic/index.less b/react-ui/src/pages/HyperParameter/components/HyperParameterBasic/index.less new file mode 100644 index 00000000..f365aa66 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/HyperParameterBasic/index.less @@ -0,0 +1,13 @@ +.hyper-parameter-basic { + height: 100%; + padding: 20px @content-padding; + overflow-y: auto; + background-color: white; + border-radius: 10px; + + :global { + .kf-basic-info__item__value__text { + white-space: pre; + } + } +} diff --git a/react-ui/src/pages/HyperParameter/components/HyperParameterBasic/index.tsx b/react-ui/src/pages/HyperParameter/components/HyperParameterBasic/index.tsx new file mode 100644 index 00000000..ea5ad15b --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/HyperParameterBasic/index.tsx @@ -0,0 +1,170 @@ +import ConfigInfo, { type BasicInfoData } from '@/components/ConfigInfo'; +import { ExperimentStatus, hyperParameterOptimizedMode } from '@/enums'; +import { useSystemResource } from '@/hooks/useComputingResource'; +import ExperimentRunBasic from '@/pages/AutoML/components/ExperimentRunBasic'; +import { + schedulerAlgorithms, + searchAlgorithms, +} from '@/pages/HyperParameter/components/CreateForm/utils'; +import { HyperParameterData } from '@/pages/HyperParameter/types'; +import { type NodeStatus } from '@/types'; +import { + formatCodeConfig, + formatDataset, + formatDate, + formatEnum, + formatMirror, + formatModel, +} from '@/utils/format'; +import classNames from 'classnames'; +import { useMemo } from 'react'; +import ParameterInfo from '../ParameterInfo'; +import styles from './index.less'; + +// 格式化优化方向 +const formatOptimizeMode = (value: string) => { + return value === hyperParameterOptimizedMode.Max ? '越大越好' : '越小越好'; +}; + +type HyperParameterBasicProps = { + info?: HyperParameterData; + className?: string; + isInstance?: boolean; + workflowStatus?: NodeStatus; + instanceStatus?: ExperimentStatus; +}; + +function HyperParameterBasic({ + info, + className, + workflowStatus, + instanceStatus, + isInstance = false, +}: HyperParameterBasicProps) { + const getResourceDescription = useSystemResource(); + + const basicDatas: BasicInfoData[] = useMemo(() => { + if (!info) { + return []; + } + + return [ + { + label: '实验名称', + value: info.name, + }, + { + label: '实验描述', + value: info.description, + }, + { + label: '创建人', + value: info.create_by, + }, + { + label: '创建时间', + value: info.create_time, + format: formatDate, + }, + { + label: '更新时间', + value: info.update_time, + format: formatDate, + }, + ]; + }, [info]); + + const configDatas: BasicInfoData[] = useMemo(() => { + if (!info) { + return []; + } + return [ + { + label: '代码配置', + value: info.code_config, + format: formatCodeConfig, + }, + { + label: '主函数代码文件', + value: info.main_py, + }, + { + label: '镜像', + value: info.image, + format: formatMirror, + }, + { + label: '数据集', + value: info.dataset, + format: formatDataset, + }, + { + label: '模型', + value: info.model, + format: formatModel, + }, + { + label: '总试验次数', + value: info.num_samples, + }, + { + label: '搜索算法', + value: info.search_alg, + format: formatEnum(searchAlgorithms), + }, + { + label: '调度算法', + value: info.scheduler, + format: formatEnum(schedulerAlgorithms), + }, + { + label: '单次试验最大时间', + value: info.max_t, + }, + { + label: '最小试验数', + value: info.min_samples_required, + }, + { + label: '指标优化方向', + value: info.mode, + format: formatOptimizeMode, + }, + { + label: '指标', + value: info.metric, + }, + { + label: '资源规格', + value: info.computing_resource_id, + format: getResourceDescription, + }, + ]; + }, [info, getResourceDescription]); + + return ( +
+ {isInstance && workflowStatus && ( + + )} + {!isInstance && ( + + )} + + {info && } + +
+ ); +} + +export default HyperParameterBasic; diff --git a/react-ui/src/pages/HyperParameter/components/ParameterInfo/index.less b/react-ui/src/pages/HyperParameter/components/ParameterInfo/index.less new file mode 100644 index 00000000..81d6fd56 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/ParameterInfo/index.less @@ -0,0 +1,7 @@ +.parameter-info { + &__title { + margin: 20px 0; + color: @text-color-secondary; + font-size: @font-size; + } +} diff --git a/react-ui/src/pages/HyperParameter/components/ParameterInfo/index.tsx b/react-ui/src/pages/HyperParameter/components/ParameterInfo/index.tsx new file mode 100644 index 00000000..337f43f7 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/ParameterInfo/index.tsx @@ -0,0 +1,107 @@ +import TableColTitle from '@/components/TableColTitle'; +import { + getReqParamName, + type FormParameter, +} from '@/pages/HyperParameter/components/CreateForm/utils'; +import { HyperParameterData } from '@/pages/HyperParameter/types'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { Table, type TableProps } from 'antd'; +import { useMemo } from 'react'; +import styles from './index.less'; + +type ParameterInfoProps = { + info: HyperParameterData; +}; + +function ParameterInfo({ info }: ParameterInfoProps) { + const parameters = useMemo(() => { + if (!info.parameters) { + return []; + } + return info.parameters.map((item) => { + const paramName = getReqParamName(item.type); + const range = item[paramName]; + return { + ...item, + range, + }; + }); + }, [info]); + + const runParameters = useMemo(() => { + if (!info.points_to_evaluate) { + return []; + } + return info.points_to_evaluate.map((item, index) => ({ + ...item, + id: index, // 作为 key,这个数组不会变化 + })); + }, [info]); + + const columns: TableProps['columns'] = [ + { + title: '参数名称', + dataIndex: 'name', + key: 'type', + width: '40%', + render: tableCellRender('auto'), + }, + { + title: '参数类型', + dataIndex: 'type', + key: 'type', + width: '20%', + render: tableCellRender(false), + }, + { + title: '取值范围', + dataIndex: 'range', + key: 'range', + width: '40%', + render: tableCellRender(true, TableCellValueType.Custom, { + format: (value) => { + return JSON.stringify(value); + }, + }), + }, + ]; + + const runColumns: TableProps>['columns'] = + runParameters.length > 0 + ? parameters.map(({ name }) => { + return { + title: , + dataIndex: name, + key: name, + width: 150, + render: tableCellRender(true), + }; + }) + : []; + + return ( +
+
超参数
+
+
手动运行超参数
+
+ + ); +} + +export default ParameterInfo; diff --git a/react-ui/src/pages/HyperParameter/components/TrialFileTree/index.less b/react-ui/src/pages/HyperParameter/components/TrialFileTree/index.less new file mode 100644 index 00000000..8d19ea0e --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/TrialFileTree/index.less @@ -0,0 +1,14 @@ +.trail-file-tree { + :global { + .ant-tree-node-selected { + .trail-file-tree__icon { + color: white; + } + } + + .trail-file-tree__icon { + margin-left: 8px; + color: @primary-color; + } + } +} diff --git a/react-ui/src/pages/HyperParameter/components/TrialFileTree/index.tsx b/react-ui/src/pages/HyperParameter/components/TrialFileTree/index.tsx new file mode 100644 index 00000000..d8c23d59 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/TrialFileTree/index.tsx @@ -0,0 +1,66 @@ +import InfoGroup from '@/components/InfoGroup'; +import KFIcon from '@/components/KFIcon'; +import { type HyperParameterFile } from '@/pages/HyperParameter/types'; +import { downLoadZip } from '@/utils/downloadfile'; +import { Tree, type TreeDataNode } from 'antd'; +import classNames from 'classnames'; +import styles from './index.less'; + +const { DirectoryTree } = Tree; + +export type TrialFileTreeProps = { + title: string; + file?: HyperParameterFile; + classname?: string; + defaultExpandAll?: boolean; +}; + +function TrialFileTree({ file, title, defaultExpandAll = true, classname }: TrialFileTreeProps) { + const filesToTreeData = ( + files: HyperParameterFile[], + parent?: HyperParameterFile, + ): TreeDataNode[] => + files.map((file) => { + const key = parent ? `${parent.name}/${file.name}` : file.name; + return { + ...file, + key, + title: file.name, + children: file.children ? filesToTreeData(file.children, file) : undefined, + }; + }); + + const treeData: TreeDataNode[] = filesToTreeData(file ? [file] : []); + return ( + + { + const label = record.title + (record.isFile ? `(${record.size})` : ''); + return ( + <> + {label} + { + e.stopPropagation(); + downLoadZip( + record.isFile + ? `/api/mmp/minioStorage/downloadFile` + : `/api/mmp/minioStorage/download`, + { path: record.url }, + ); + }} + /> + + ); + }} + /> + + ); +} + +export default TrialFileTree; diff --git a/react-ui/src/pages/HyperParameter/components/TrialStatusCell/index.less b/react-ui/src/pages/HyperParameter/components/TrialStatusCell/index.less new file mode 100644 index 00000000..6bdaf5bc --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/TrialStatusCell/index.less @@ -0,0 +1,3 @@ +.trial-status-cell { + height: 100%; +} diff --git a/react-ui/src/pages/HyperParameter/components/TrialStatusCell/index.tsx b/react-ui/src/pages/HyperParameter/components/TrialStatusCell/index.tsx new file mode 100644 index 00000000..8838e175 --- /dev/null +++ b/react-ui/src/pages/HyperParameter/components/TrialStatusCell/index.tsx @@ -0,0 +1,67 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-18 18:35:41 + * @Description: 实验状态 + */ + +import { HyperParameterTrailStatus } from '@/enums'; +import { ExperimentStatusInfo } from '@/pages/Experiment/status'; +import themes from '@/styles/theme.less'; +import styles from './index.less'; + +export const statusInfo: Record = { + [HyperParameterTrailStatus.RUNNING]: { + label: '运行中', + color: themes.primaryColor, + icon: '/assets/images/experiment-status/running-icon.png', + }, + [HyperParameterTrailStatus.TERMINATED]: { + label: '成功', + color: themes.successColor, + icon: '/assets/images/experiment-status/success-icon.png', + }, + [HyperParameterTrailStatus.PENDING]: { + label: '挂起', + color: themes.pendingColor, + icon: '/assets/images/experiment-status/pending-icon.png', + }, + [HyperParameterTrailStatus.ERROR]: { + label: '失败', + color: themes.errorColor, + icon: '/assets/images/experiment-status/fail-icon.png', + }, + [HyperParameterTrailStatus.PAUSED]: { + label: '暂停', + color: themes.abortColor, + icon: '/assets/images/experiment-status/omitted-icon.png', + }, + [HyperParameterTrailStatus.RESTORING]: { + label: '恢复中', + color: themes.textColor, + icon: '/assets/images/experiment-status/omitted-icon.png', + }, +}; + +function TrialStatusCell(status?: HyperParameterTrailStatus | null) { + if (status === null || status === undefined) { + return --; + } + return ( +
+ {/* */} + + {statusInfo[status] ? statusInfo[status].label : status} + +
+ ); +} + +export default TrialStatusCell; diff --git a/react-ui/src/pages/HyperParameter/types.ts b/react-ui/src/pages/HyperParameter/types.ts new file mode 100644 index 00000000..780e203d --- /dev/null +++ b/react-ui/src/pages/HyperParameter/types.ts @@ -0,0 +1,84 @@ +import { type ParameterInputObject } from '@/components/ResourceSelect'; +import { type NodeStatus } from '@/types'; +import { type FormParameter } from './components/CreateForm/utils'; + +// 操作类型 +export enum OperationType { + Create = 'Create', // 创建 + Update = 'Update', // 更新 +} + +// 表单数据 +export type FormData = { + name: string; // 实验名称 + description: string; // 实验描述 + code_config: ParameterInputObject; // 代码 + dataset: ParameterInputObject; // 数据集 + model: ParameterInputObject; // 模型 + image: ParameterInputObject; // 镜像 + main_py: string; // 主函数代码文件 + metric: string; // 指标 + mode: string; // 优化方向 + search_alg?: string; // 搜索算法 + scheduler?: string; // 调度算法 + num_samples: number; // 总试验次数 + max_t: number; // 单次试验最大时间 + min_samples_required: number; // 计算中位数的最小试验数 + computing_resource_id: number; // 资源规格 + parameters: FormParameter[]; + points_to_evaluate: { [key: string]: any }[]; +}; + +export type HyperParameterData = { + id: number; + progress: number; + run_state: string; + state: number; + create_by?: string; + create_time?: string; + update_by?: string; + update_time?: string; + status_list: string; // 最近五次运行状态 +} & FormData; + +// 实验实例 +export type HyperParameterInstanceData = { + id: number; + ray_id: number; + result_path: string; + result_txt: string; + state: number; + status: string; + node_status: string; + node_result: string; + param: string; + source: string | null; + argo_ins_name: string; + argo_ins_ns: string; + create_time: string; + update_time: string; + finish_time: string; + nodeStatus?: NodeStatus; // json之后的节点状态 + trial_list?: HyperParameterTrial[]; + file_list?: HyperParameterFile[]; +}; + +export type HyperParameterTrial = { + trial_id: string; + training_iteration?: number; + time?: number; + status?: string; + config?: Record; + metric_analysis?: Record; + metric: string; + file: HyperParameterFile; + is_best?: boolean; +}; + +export type HyperParameterFile = { + name: string; + size: string; + url: string; + isFile: boolean; + children: HyperParameterFile[]; +}; diff --git a/react-ui/src/pages/Knowledge/index.tsx b/react-ui/src/pages/Knowledge/index.tsx new file mode 100644 index 00000000..a00e5e6c --- /dev/null +++ b/react-ui/src/pages/Knowledge/index.tsx @@ -0,0 +1,12 @@ +/* + * @Author: 赵伟 + * @Date: 2025-04-21 16:38:59 + * @Description: 知识图谱 + */ + +import IframePage, { IframePageType } from '@/components/IFramePage'; + +function KnowledgePage() { + return ; +} +export default KnowledgePage; diff --git a/react-ui/src/pages/Message/components/Content/index.less b/react-ui/src/pages/Message/components/Content/index.less new file mode 100644 index 00000000..b99e753f --- /dev/null +++ b/react-ui/src/pages/Message/components/Content/index.less @@ -0,0 +1,138 @@ +.message-content { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + height: 100%; + .backgroundFullImage(url(@/assets/img/message/content-bg.png)); + + &__tabs { + display: flex; + flex: none; + align-items: center; + height: 76px; + padding: 0 30px; + border-bottom: 1px dashed rgba(130, 132, 164, 0.18); + + &__item { + margin-right: 20px; + color: @text-color-secondary; + font-size: @font-size; + + &--selected, + &:hover { + color: @text-color; + font-weight: 500; + } + } + + :global { + .ant-btn:first-of-type { + margin-right: 10px; + margin-left: auto; + } + } + } + + &__check-container { + display: flex; + align-items: center; + margin: 16px 0 10px; + padding-left: 30px; + color: @text-color-secondary; + font-size: @font-size; + + &__count { + margin: 0 2px; + color: @primary-color; + } + } + + &__list { + flex: 1; + width: 100%; + overflow-y: auto; + + &__item { + display: flex; + align-items: center; + width: 100%; + height: 56px; + padding: 0 30px; + color: @text-color; + font-size: @font-size; + + &__status { + flex: none; + margin-right: 10px; + padding: 2px 4px; + font-size: 12px; + border-radius: 4px; + + &--unread { + color: #d7312a; + background-color: rgba(215, 49, 42, 0.07); + } + + &--readed { + color: #2a814b; + background-color: rgba(42, 129, 75, 0.07); + } + } + + &__content { + flex: 1; + margin-right: 10px; + } + + &__time { + display: block; + flex: none; + margin-left: auto; + color: @text-color-secondary; + } + + &__button { + display: none; + flex: none; + padding-right: 0; + padding-left: 0; + color: @primary-color-hover; + font-size: @font-size; + + &:hover { + color: @primary-color !important; + } + + &:first-of-type { + margin-right: 10px; + margin-left: auto; + } + } + + &:hover { + color: @primary-color; + background-color: .addAlpha(@primary-color, 0.05) []; + } + + &:hover &__button { + display: block; + } + + &:hover &__time { + display: none; + } + } + } + + :global { + .ant-pagination { + margin-right: 30px; + margin-bottom: 40px; + } + } + + &__empty { + flex: 1; + } +} diff --git a/react-ui/src/pages/Message/components/Content/index.tsx b/react-ui/src/pages/Message/components/Content/index.tsx new file mode 100644 index 00000000..8b157ff9 --- /dev/null +++ b/react-ui/src/pages/Message/components/Content/index.tsx @@ -0,0 +1,374 @@ +import KFButton from '@/components/KFButton'; +import KFEmpty, { EmptyType } from '@/components/KFEmpty'; +import { MessageStatus, MessageType } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { useCheck } from '@/hooks/useCheck'; +import { Message, MessageResponse } from '@/pages/Message'; +import { deleteMessagesReq, getMessageListReq, readMessagesReq } from '@/services/message'; +import { ago } from '@/utils/date'; +import { to } from '@/utils/promise'; +import { modalConfirm } from '@/utils/ui'; +import { useModel, useNavigate } from '@umijs/max'; +import { + Button, + Checkbox, + Pagination, + PaginationProps, + Typography, + message, + type TablePaginationConfig, +} from 'antd'; +import classNames from 'classnames'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import styles from './index.less'; + +export type MessageContentProps = { + messageType: MessageType; + messageStatus: MessageStatus; + pagination: TablePaginationConfig; + onStatusChange: (status: MessageStatus) => void; + onPaginationChange: (pagination: TablePaginationConfig) => void; +}; + +function MessageContent({ + messageType, + messageStatus, + pagination, + onStatusChange, + onPaginationChange, +}: MessageContentProps) { + const { initialState } = useModel('@@initialState'); + const { currentUser } = initialState || {}; + const { userId } = currentUser || {}; + const setCacheState = useCacheState()[1]; + const [messages, setMessages] = useState(undefined); + const [allTotal, setAllTotal] = useState(0); + const [unreadTotal, setUnreadTotal] = useState(undefined); + const [isDelete, setIsDelete] = useState(false); + const messageIds = useMemo(() => messages?.map((v) => v.id), [messages]); + const [ + selectedMessages, + setSelectedMessages, + messagesAllChecked, + messagesIndeterminate, + checkAllMessages, + isSingleMessagesChecked, + checkSingleMessages, + ] = useCheck(messageIds ?? []); + const navigate = useNavigate(); + + const tabs = useMemo( + () => [ + { + title: '未读', + status: MessageStatus.UnRead, + total: unreadTotal, + }, + { + title: '全部', + status: MessageStatus.All, + }, + ], + [unreadTotal], + ); + + const getMessages = useCallback(async () => { + if (!userId) return; + + const params: Record = { + receiver: userId, + status: messageStatus, + type: messageType, + page: pagination.current, + size: pagination.pageSize, + }; + const [res] = await to(getMessageListReq(params)); + if (res && res.data) { + const { records, records_count, unread_notification, unread_atme } = + res.data as MessageResponse; + setMessages(records); + setAllTotal(records_count); + setUnreadTotal(messageType === MessageType.System ? unread_notification : unread_atme); + } + }, [pagination, userId, messageStatus, messageType]); + + // 标记为已读 + const readMessages = async ( + message?: Message, + skipLoading: boolean = false, + skipResult: boolean = false, + ) => { + const params: Record = message + ? { + notificationIds: message.id, + status: MessageStatus.Readed, + receiver: message.receiver, + type: message.type, + } + : { + notificationIds: -1, + status: MessageStatus.Readed, + receiver: userId, + type: messageType, + }; + const [res] = await to(readMessagesReq(params, skipLoading)); + + // 点击消息置为已读时,不需要修改数据 + if (!skipResult && res) { + // 如果当前是【未读】状态 + // 【一键已读】后,设置分页为第一页 + // 如果是一页的唯一数据,设置为前一页 + if (messageStatus === MessageStatus.UnRead) { + onPaginationChange({ + ...pagination, + current: message + ? messages?.length === 1 + ? Math.max(1, pagination.current! - 1) + : pagination.current + : 1, + }); + } + } else { + getMessages(); + } + }; + + // 删除 + const deleteMessages = async (ids: number[]) => { + if (ids.length <= 0) { + message.error('请选择要删除的消息'); + return; + } + + const params: Record = { + notificationIds: ids.join(','), + receiver: userId, + type: messageType, + }; + const [res] = await to(deleteMessagesReq(params)); + if (res) { + cancelBatchDelete(); + // 如果是一页的唯一数据,删除后,请求前一页的数据 + // 否则直接刷新这一页的数据 + onPaginationChange({ + ...pagination, + current: + ids.length === messages?.length + ? Math.max(1, pagination.current! - 1) + : pagination.current, + }); + } + }; + + // 取消批量删除 + const cancelBatchDelete = useCallback(() => { + setIsDelete(false); + setSelectedMessages([]); + }, [setSelectedMessages]); + + useEffect(() => { + getMessages(); + }, [getMessages]); + + // 重置批量删除状态、分页 + useEffect(() => { + cancelBatchDelete(); + }, [messageType, messageStatus, cancelBatchDelete]); + + // 批量删除 + const handleBatchDelete = () => { + if (selectedMessages.length <= 0) { + message.error('请选择要删除的消息'); + return; + } + + modalConfirm({ + title: '删除后,消息不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteMessages(selectedMessages); + }, + }); + }; + + // 点击消息 + const hanldeMessageClick = (message: Message) => { + if (message.status === MessageStatus.UnRead) { + readMessages(message, true, true); + } + + if (message.notification_url) { + navigate(message.notification_url); + setCacheState({ + messageType, + pagination, + messageStatus, + }); + } + }; + + // 分页切换 + const handlePageChange: PaginationProps['onChange'] = (page, pageSize) => { + onPaginationChange({ + current: page, + pageSize: pageSize, + }); + }; + + return ( +
+
+ {tabs.map((item) => ( +
{ + onStatusChange(item.status); + }} + > + {item.title + (item.total !== undefined ? `(${item.total})` : '')} +
+ ))} + + {isDelete ? ( + <> + + 取消 + + + 删除 + + + ) : ( + <> + {messageType === MessageType.Mine && allTotal > 0 && ( + setIsDelete(true)}> + 批量删除 + + )} + {allTotal > 0 && ( + readMessages()}> + 一键已读 + + )} + + )} +
+ + {isDelete && ( +
+ + 全选 + + + 已选 + + {selectedMessages.length} + + 项 + +
+ )} + + {messages && messages.length > 0 && ( + <> +
+ {messages.map((message) => ( +
hanldeMessageClick(message)} + > + {messageType === MessageType.Mine && isDelete && ( + { + checkSingleMessages(message.id); + }} + onClick={(e) => e.stopPropagation()} + > + )} + {messageStatus === MessageStatus.All && ( +
+ {message.status === MessageStatus.UnRead ? '未读' : '已读'} +
+ )} + /g, '') }} + > + + + +
+ {ago(message.created_at)} +
+ {message.status === MessageStatus.UnRead && ( + + )} + {messageType === MessageType.Mine && ( + + )} +
+ ))} +
+ + + )} + {messages && messages.length === 0 && ( + + )} +
+ ); +} + +export default MessageContent; diff --git a/react-ui/src/pages/Message/components/Menu/index.less b/react-ui/src/pages/Message/components/Menu/index.less new file mode 100644 index 00000000..dbfba954 --- /dev/null +++ b/react-ui/src/pages/Message/components/Menu/index.less @@ -0,0 +1,69 @@ +.message-menu { + flex: none; + width: 196px; + height: 100%; + .backgroundFullImage(url(@/assets/img/message/menu-bg.png)); + + &__title { + position: relative; + margin-bottom: 25px; + padding: 20px 20px 10px; + color: @text-color; + font-size: @font-size; + + &::after { + position: absolute; + right: 20px; + bottom: 0; + left: 20px; + border-bottom: 1px dashed rgba(130, 132, 164, 0.18); + content: ''; + } + } + + &__item { + display: flex; + align-items: center; + margin-bottom: 4px; + padding: 10px 0 10px 18px; + color: @text-color-secondary; + font-size: @font-size; + border-left: 2px solid transparent; + + &--selected, + &:hover { + color: @primary-color; + background-image: linear-gradient( + 101.08deg, + rgba(81, 76, 249, 0.09) 0%, + rgba(255, 255, 255, 0) 100% + ); + border-left: 2px solid @primary-color; + } + + &__icon, + &__icon--hover { + width: 18px; + height: 18px; + margin-right: 10px; + } + + &__icon { + display: block; + } + + &__icon--hover { + display: none; + } + + &--selected &__icon, + &__item:hover &__icon { + display: none; + } + + &--selected &__icon--hover, + &__item:hover &__icon--hover { + display: block; + } + } +} diff --git a/react-ui/src/pages/Message/components/Menu/index.tsx b/react-ui/src/pages/Message/components/Menu/index.tsx new file mode 100644 index 00000000..3f5b0513 --- /dev/null +++ b/react-ui/src/pages/Message/components/Menu/index.tsx @@ -0,0 +1,50 @@ +import AtHoverIcon from '@/assets/img/message/at-hover.png'; +import AtIcon from '@/assets/img/message/at.png'; +import SystemHoverIcon from '@/assets/img/message/system-hover.png'; +import SystemIcon from '@/assets/img/message/system.png'; +import { MessageType } from '@/enums'; +import classNames from 'classnames'; +import styles from './index.less'; + +const menus = [ + { + title: '系统消息', + icon: SystemIcon, + hoverIcon: SystemHoverIcon, + type: MessageType.System, + }, + { + title: '@我的', + icon: AtIcon, + hoverIcon: AtHoverIcon, + type: MessageType.Mine, + }, +]; + +export type MessageMenuProps = { + messageType: MessageType; + onTypeChange: (type: MessageType) => void; +}; + +function MessageMenu({ messageType: currentType, onTypeChange }: MessageMenuProps) { + return ( +
+
消息列表
+ {menus.map((item) => ( +
onTypeChange(item.type)} + > + + + {item.title} +
+ ))} +
+ ); +} + +export default MessageMenu; diff --git a/react-ui/src/pages/Message/index.less b/react-ui/src/pages/Message/index.less new file mode 100644 index 00000000..745c538c --- /dev/null +++ b/react-ui/src/pages/Message/index.less @@ -0,0 +1,8 @@ +.message { + display: flex; + flex-direction: row; + gap: 0 20px; + height: 100%; + padding: 30px 60px 30px; + .backgroundFullImage(url(@/assets/img/message/message-bg.png)); +} diff --git a/react-ui/src/pages/Message/index.tsx b/react-ui/src/pages/Message/index.tsx new file mode 100644 index 00000000..aba242cd --- /dev/null +++ b/react-ui/src/pages/Message/index.tsx @@ -0,0 +1,81 @@ +import { MessageStatus, MessageType } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { type TablePaginationConfig } from 'antd'; +import { useState } from 'react'; +import MessageContent from './components/Content'; +import MessageMenu from './components/Menu'; +import styles from './index.less'; + +// 消息列表接口返回类型 +export interface MessageResponse { + receiver: number; + type: MessageType; + unread_total: number; + unread_notification: number; + unread_atme: number; + records: Message[]; + records_count: number; + page_num: number; + total_page_count: number; + page_size: number; +} + +// 消息数据 +export interface Message { + id: number; + sender: number; + receiver: number; + content: string; + status: MessageStatus; + type: MessageType; + source: string; + extra: string; + notification_url: string; + created_at: Date; +} + +function MessagePage() { + const [cacheState] = useCacheState(); + const [messageType, setMessageType] = useState(cacheState?.messageType ?? MessageType.System); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 20, + }, + ); + const [messageStatus, setMessageStatus] = useState( + cacheState?.messageStatus ?? MessageStatus.UnRead, + ); + + // 重置页面为第一页 + const resetToFirstPage = () => { + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + return ( +
+ { + setMessageType(type); + resetToFirstPage(); + }} + messageType={messageType} + > + { + setMessageStatus(status); + resetToFirstPage(); + }} + pagination={pagination} + onPaginationChange={setPagination} + > +
+ ); +} + +export default MessagePage; diff --git a/react-ui/src/pages/Mirror/Create/index.less b/react-ui/src/pages/Mirror/Create/index.less new file mode 100644 index 00000000..403a2a44 --- /dev/null +++ b/react-ui/src/pages/Mirror/Create/index.less @@ -0,0 +1,25 @@ +.mirror-create { + height: 100%; + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 30px 30px 10px; + overflow: auto; + background-color: white; + border-radius: 10px; + + &__name-row { + :global { + .ant-form-item-row { + flex-wrap: nowrap; + } + } + } + + &__type { + color: @text-color; + font-size: @font-size-input-lg; + } + } +} diff --git a/react-ui/src/pages/Mirror/Create/index.tsx b/react-ui/src/pages/Mirror/Create/index.tsx new file mode 100644 index 00000000..ebbd12e7 --- /dev/null +++ b/react-ui/src/pages/Mirror/Create/index.tsx @@ -0,0 +1,354 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 创建镜像 + */ +import { getAccessToken } from '@/access'; +import KFIcon from '@/components/KFIcon'; +import KFRadio, { type KFRadioItem } from '@/components/KFRadio'; +import PageTitle from '@/components/PageTitle'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { CommonTabKeys } from '@/enums'; +import { createMirrorReq } from '@/services/mirror'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import { getFileListFromEvent, validateUploadFiles } from '@/utils/ui'; +import { useNavigate } from '@umijs/max'; +import { App, Button, Col, Form, Input, Row, Upload, UploadFile, type UploadProps } from 'antd'; +import { omit } from 'lodash'; +import { useEffect, useState } from 'react'; +import styles from './index.less'; + +type FormData = { + name: string; + tag: string; + description: string; + path?: string; + upload_type: string; + fileList?: UploadFile[]; +}; + +const mirrorRadioItems: KFRadioItem[] = [ + { + title: '基于公网镜像', + value: CommonTabKeys.Public, + icon: , + }, + { + title: '本地上传', + value: CommonTabKeys.Private, + icon: , + }, +]; + +function MirrorCreate() { + const navigate = useNavigate(); + const [form] = Form.useForm(); + const [isAddVersion, setIsAddVersion] = useState(false); // 是制作镜像还是新增镜像版本 + const { message } = App.useApp(); + + const uploadProps: UploadProps = { + action: '/api/mmp/image/upload', + headers: { + Authorization: getAccessToken() || '', + }, + maxCount: 1, + defaultFileList: [], + }; + + useEffect(() => { + const name = SessionStorage.getItem(SessionStorage.mirrorNameKey); + if (name) { + form.setFieldValue('name', name); + setIsAddVersion(true); + } + return () => { + SessionStorage.removeItem(SessionStorage.mirrorNameKey); + }; + }, [form]); + + // 创建公网、本地镜像 + const createPublicMirror = async (formData: FormData) => { + const upload_type = formData['upload_type']; + + if (upload_type === CommonTabKeys.Public) { + const params = { + ...omit(formData, ['upload_type']), + upload_type: 0, + image_type: 0, + }; + const [res] = await to(createMirrorReq(params)); + if (res) { + message.success('创建成功'); + navigate(-1); + } + } else { + const fileList = formData['fileList'] ?? []; + if (validateUploadFiles(fileList)) { + const file = fileList[0]; + const params = { + ...omit(formData, ['fileList', 'upload_type']), + path: file.response.data.url, + file_size: file.response.data.fileSize, + file_name: file.response.data.fileName, + upload_type: 1, + image_type: 0, + }; + const [res] = await to(createMirrorReq(params)); + if (res) { + message.success('创建成功'); + navigate(-1); + } + } + } + }; + + // 提交 + const handleSubmit = (values: FormData) => { + createPublicMirror(values); + }; + + // 取消 + const cancel = () => { + navigate(-1); + }; + + // 上传前认证 + const beforeUpload: UploadProps['beforeUpload'] = () => { + const fileList = form.getFieldValue('fileList'); + if (Array.isArray(fileList) && fileList.length >= 1) { + message.error('只允许上传一个文件'); + return Upload.LIST_IGNORE; + } + return true; + }; + + return ( +
+ +
+
+
+ + +
+ + + + + + + + + + + {!isAddVersion && ( + + + + + + + + )} + + + + + + + + + + + + + + + + + prevValues.upload_type !== curValues.upload_type + } + > + {({ getFieldValue }) => { + const type = getFieldValue('upload_type'); + if (type === CommonTabKeys.Public) { + return ( + <> + + + + 公网 + + + + + + + + + + + + ); + } else { + return ( + <> + + + + + + + + + + + ); + } + }} + + + + + + + + + + + ); +} + +export default MirrorCreate; diff --git a/react-ui/src/pages/Mirror/Info/index.less b/react-ui/src/pages/Mirror/Info/index.less new file mode 100644 index 00000000..adee2d49 --- /dev/null +++ b/react-ui/src/pages/Mirror/Info/index.less @@ -0,0 +1,35 @@ +.mirror-info { + height: 100%; + + &__basic { + &__item { + display: flex; + align-items: flex-start; + font-size: 16px; + line-height: 1.6; + + .label { + width: 80px; + color: @text-color-secondary; + } + + .value { + flex: 1; + color: @text-color; + } + } + } + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 30px 30px 0; + background-color: white; + border-radius: 10px; + + &__title { + display: flex; + align-items: center; + } + } +} diff --git a/react-ui/src/pages/Mirror/Info/index.tsx b/react-ui/src/pages/Mirror/Info/index.tsx new file mode 100644 index 00000000..1a925831 --- /dev/null +++ b/react-ui/src/pages/Mirror/Info/index.tsx @@ -0,0 +1,329 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 镜像详情 + */ +import KFIcon from '@/components/KFIcon'; +import PageTitle from '@/components/PageTitle'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { MirrorVersionStatus } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { useDomSize } from '@/hooks/useDomSize'; +import { + deleteMirrorVersionReq, + getMirrorInfoReq, + getMirrorVersionListReq, +} from '@/services/mirror'; +import themes from '@/styles/theme.less'; +import { formatDate } from '@/utils/date'; +import { safeInvoke } from '@/utils/functional'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { modalConfirm } from '@/utils/ui'; +import { useNavigate, useParams } from '@umijs/max'; +import { + App, + Button, + Col, + ConfigProvider, + Flex, + Row, + Table, + type TablePaginationConfig, + type TableProps, +} from 'antd'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import MirrorStatusCell from '../components/MirrorStatusCell'; +import styles from './index.less'; + +export type MirrorInfoData = { + name?: string; + description?: string; + version_count?: string; + create_time?: string; + image_type?: number; +}; + +export type MirrorVersionData = { + image_id: number; + id: number; + version: string; + url: string; + status: MirrorVersionStatus; + file_size: string; + create_time: string; + tag_name: string; + description: string; +}; + +function MirrorInfo() { + const navigate = useNavigate(); + const urlParams = useParams(); + const [cacheState, setCacheState] = useCacheState(); + const [mirrorInfo, setMirrorInfo] = useState({}); + const [tableData, setTableData] = useState([]); + const [topRef, { height: topHeight }] = useDomSize(0, 0, [mirrorInfo]); + const [total, setTotal] = useState(0); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 10, + }, + ); + const { message } = App.useApp(); + const isPublic = useMemo(() => mirrorInfo.image_type === 1, [mirrorInfo]); + const mirrorId = safeInvoke(Number)(urlParams.id); + + // 获取镜像详情 + const getMirrorInfo = useCallback(async () => { + if (!mirrorId) { + return; + } + const [res] = await to(getMirrorInfoReq(mirrorId)); + if (res && res.data) { + setMirrorInfo(res.data); + } + }, [mirrorId]); + + // 获取镜像版本列表 + const getMirrorVersionList = useCallback(async () => { + if (!mirrorId) { + return; + } + const params = { + page: pagination.current! - 1, + size: pagination.pageSize, + image_id: mirrorId, + }; + const [res] = await to(getMirrorVersionListReq(params)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + setTableData(content); + setTotal(totalElements); + } + }, [mirrorId, pagination]); + + // 获取镜像详情 + useEffect(() => { + getMirrorInfo(); + }, [getMirrorInfo]); + + // 获取镜像版本列表 + useEffect(() => { + getMirrorVersionList(); + }, [getMirrorVersionList]); + + // 删除镜像版本 + const deleteMirrorVersion = async (id: number) => { + const [res] = await to(deleteMirrorVersionReq(id)); + if (res) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: tableData.length === 1 ? Math.max(1, prev.current! - 1) : prev.current, + }; + }); + getMirrorInfo(); + } + }; + + // 分页切换 + const handleTableChange: TableProps['onChange'] = ( + pagination, + _filters, + _sorter, + { action }, + ) => { + if (action === 'paginate') { + setPagination(pagination); + } + }; + + // 处理删除 + const handleVersionDelete = (record: MirrorVersionData) => { + modalConfirm({ + title: '删除后,该镜像版本将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteMirrorVersion(record.id); + }, + }); + }; + + const createMirrorVersion = () => { + navigate(`add-version`); + SessionStorage.setItem(SessionStorage.mirrorNameKey, mirrorInfo.name || ''); + setCacheState({ + pagination, + }); + }; + + const columns: TableProps['columns'] = [ + { + title: '镜像版本', + dataIndex: 'tag_name', + key: 'tag_name', + width: '30%', + render: tableCellRender(), + }, + { + title: '镜像地址', + dataIndex: 'url', + key: 'url', + width: '40%', + render: tableCellRender('auto', TableCellValueType.Text, { copyable: true }), + }, + { + title: '版本描述', + dataIndex: 'description', + key: 'description', + width: '30%', + render: tableCellRender(true), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: MirrorStatusCell, + }, + { + title: '镜像大小', + dataIndex: 'file_size', + key: 'file_size', + width: 120, + render: tableCellRender(), + }, + { + title: '创建时间', + dataIndex: 'create_time', + key: 'create_time', + width: 200, + render: tableCellRender(false, TableCellValueType.Date), + }, + { + title: '操作', + dataIndex: 'operation', + width: 120, + key: 'operation', + hidden: isPublic, + render: (_: any, record: MirrorVersionData) => ( +
+ {!isPublic && record.status && record.status !== MirrorVersionStatus.Building && ( + + + + )} +
+ ), + }, + ]; + + return ( +
+ +
+
+ +
+ +
+
+
镜像名称:
+
{mirrorInfo.name}
+
+ + +
+
版本数:
+
{mirrorInfo.version_count ?? '--'}
+
+ + + + +
+
镜像描述:
+
{mirrorInfo.description}
+
+ + +
+
创建时间:
+
{formatDate(mirrorInfo.create_time)}
+
+ + + + + + {!isPublic && ( + + )} + + + +
+
`共${total}条`, + }} + onChange={handleTableChange} + rowKey="id" + /> + + + + ); +} + +export default MirrorInfo; diff --git a/react-ui/src/pages/Mirror/List/index.less b/react-ui/src/pages/Mirror/List/index.less new file mode 100644 index 00000000..6acc2b14 --- /dev/null +++ b/react-ui/src/pages/Mirror/List/index.less @@ -0,0 +1,30 @@ +.mirror-list { + height: 100%; + &__tabs-container { + height: 50px; + padding-left: 27px; + background-image: url(@/assets/img/page-title-bg.png); + background-repeat: no-repeat; + background-position: top center; + background-size: 100% 100%; + } + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 20px 30px 0; + background-color: white; + border-radius: 10px; + + &__filter { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__table { + height: calc(100% - 32px - 28px); + margin-top: 28px; + } + } +} diff --git a/react-ui/src/pages/Mirror/List/index.tsx b/react-ui/src/pages/Mirror/List/index.tsx new file mode 100644 index 00000000..7dd57234 --- /dev/null +++ b/react-ui/src/pages/Mirror/List/index.tsx @@ -0,0 +1,297 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 镜像列表 + */ +import KFIcon from '@/components/KFIcon'; +import { CommonTabKeys } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { deleteMirrorReq, getMirrorListReq } from '@/services/mirror'; +import themes from '@/styles/theme.less'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { modalConfirm } from '@/utils/ui'; +import { useNavigate } from '@umijs/max'; +import { + App, + Button, + ConfigProvider, + Input, + Table, + Tabs, + type TablePaginationConfig, + type TableProps, + type TabsProps, +} from 'antd'; +import { type SearchProps } from 'antd/es/input'; +import classNames from 'classnames'; +import { useCallback, useEffect, useState } from 'react'; +import styles from './index.less'; + +const mirrorTabItems = [ + { + key: CommonTabKeys.Public, + label: '公共镜像', + icon: , + }, + { + key: CommonTabKeys.Private, + label: '个人镜像', + icon: , + }, +]; + +export type MirrorData = { + id: number; + name: string; + description: string; + create_time: string; + create_by: string; + version_count: number; +}; + +function MirrorList() { + const navigate = useNavigate(); + const [cacheState, setCacheState] = useCacheState(); + const [activeTab, setActiveTab] = useState(cacheState?.activeTab ?? CommonTabKeys.Public); + const [searchText, setSearchText] = useState(cacheState?.searchText); + const [inputText, setInputText] = useState(cacheState?.searchText); + const [tableData, setTableData] = useState([]); + const [total, setTotal] = useState(0); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 10, + }, + ); + const { message } = App.useApp(); + + // 获取镜像列表 + const getMirrorList = useCallback(async () => { + const params: Record = { + page: pagination.current! - 1, + size: pagination.pageSize, + name: searchText || undefined, + image_type: activeTab === CommonTabKeys.Public ? 1 : 0, + }; + const [res] = await to(getMirrorListReq(params)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + setTableData(content); + setTotal(totalElements); + } + }, [activeTab, pagination, searchText]); + + useEffect(() => { + getMirrorList(); + }, [getMirrorList]); + + // 切换 Tab,重置数据 + const hanleTabChange: TabsProps['onChange'] = (value) => { + setSearchText(''); + setInputText(''); + setPagination({ + current: 1, + pageSize: 10, + }); + setTotal(0); + setTableData([]); + setActiveTab(value); + }; + + // 删除镜像 + const deleteMirror = async (id: number) => { + const [res] = await to(deleteMirrorReq(id)); + if (res) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: tableData.length === 1 ? Math.max(1, prev.current! - 1) : prev.current, + }; + }); + } + }; + + // 搜索 + const onSearch: SearchProps['onSearch'] = (value) => { + setSearchText(value); + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + // 查看详情 + const toDetail = (record: MirrorData) => { + navigate(`info/${record.id}`); + setCacheState({ + activeTab, + pagination, + searchText, + }); + }; + + // 处理删除 + const handleMirrorDelete = (record: MirrorData) => { + modalConfirm({ + title: '删除后,该镜像将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteMirror(record.id); + }, + }); + }; + + // 创建镜像 + const createMirror = () => { + navigate(`create`); + SessionStorage.setItem(SessionStorage.mirrorNameKey, ''); + setCacheState({ + activeTab, + pagination, + searchText, + }); + }; + + // 分页切换 + const handleTableChange: TableProps['onChange'] = ( + pagination, + _filters, + _sorter, + { action }, + ) => { + if (action === 'paginate') { + setPagination(pagination); + } + // console.log(pagination, filters, sorter, action); + }; + + const columns: TableProps['columns'] = [ + { + title: '镜像名称', + dataIndex: 'name', + key: 'name', + width: '30%', + render: tableCellRender(), + }, + { + title: '版本数据', + dataIndex: 'version_count', + key: 'version_count', + width: '15%', + render: tableCellRender(), + }, + { + title: '镜像描述', + dataIndex: 'description', + key: 'description', + width: '35%', + render: tableCellRender(true), + }, + { + title: '创建时间', + dataIndex: 'create_time', + key: 'create_time', + width: '20%', + render: tableCellRender(false, TableCellValueType.Date), + }, + { + title: '操作', + dataIndex: 'operation', + width: activeTab === CommonTabKeys.Private ? 200 : 150, + key: 'operation', + render: (_: any, record: MirrorData) => ( +
+ + {activeTab === CommonTabKeys.Private && ( + + + + )} +
+ ), + }, + ]; + + return ( +
+
+ +
+
+
+ setInputText(e.target.value)} + style={{ width: 300 }} + value={inputText} + /> + {activeTab === CommonTabKeys.Private && ( + + )} + +
+
+
`共${total}条`, + }} + onChange={handleTableChange} + rowKey="id" + /> + + + + ); +} + +export default MirrorList; diff --git a/react-ui/src/pages/Mirror/components/MirrorStatusCell/index.less b/react-ui/src/pages/Mirror/components/MirrorStatusCell/index.less new file mode 100644 index 00000000..043bf411 --- /dev/null +++ b/react-ui/src/pages/Mirror/components/MirrorStatusCell/index.less @@ -0,0 +1,11 @@ +.mirror-status-cell { + color: @text-color; + + &--success { + color: @success-color; + } + + &--error { + color: @error-color; + } +} diff --git a/react-ui/src/pages/Mirror/components/MirrorStatusCell/index.tsx b/react-ui/src/pages/Mirror/components/MirrorStatusCell/index.tsx new file mode 100644 index 00000000..fc0548c1 --- /dev/null +++ b/react-ui/src/pages/Mirror/components/MirrorStatusCell/index.tsx @@ -0,0 +1,36 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-18 18:35:41 + * @Description: 镜像状态组件 + */ +import { MirrorVersionStatus } from '@/enums'; +import styles from './index.less'; + +export type MirrorVersionStatusInfo = { + text: string; + classname: string; +}; + +const statusInfo: Record = { + [MirrorVersionStatus.Building]: { + text: '构建中', + classname: styles['mirror-status-cell'], + }, + [MirrorVersionStatus.Available]: { + classname: styles['mirror-status-cell--success'], + text: '可用', + }, + [MirrorVersionStatus.Failed]: { + classname: styles['mirror-status-cell--error'], + text: '构建失败', + }, +}; + +function MirrorStatusCell(status?: MirrorVersionStatus | null) { + if (status === null || status === undefined || !statusInfo[status]) { + return --; + } + return {statusInfo[status].text}; +} + +export default MirrorStatusCell; diff --git a/react-ui/src/pages/Mixed/index.less b/react-ui/src/pages/Mixed/index.less new file mode 100644 index 00000000..911ec33c --- /dev/null +++ b/react-ui/src/pages/Mixed/index.less @@ -0,0 +1,3 @@ +.mixed { + height: 100%; +} diff --git a/react-ui/src/pages/Mixed/index.tsx b/react-ui/src/pages/Mixed/index.tsx new file mode 100644 index 00000000..c9d657d9 --- /dev/null +++ b/react-ui/src/pages/Mixed/index.tsx @@ -0,0 +1,25 @@ +import { MicroAppWithMemoHistory } from '@umijs/max'; +import { Tabs } from 'antd'; +import styles from './index.less'; + +const Docs = () => { + const mirrorTabItems = [ + { + key: '1', + label: '父页面', + children:
Parent
, + }, + { + key: '2', + label: '子页面', + children: , + }, + ]; + + return ( +
+ +
+ ); +}; +export default Docs; diff --git a/react-ui/src/pages/Model/components/GraphLegend/index.less b/react-ui/src/pages/Model/components/GraphLegend/index.less new file mode 100644 index 00000000..9a7faee7 --- /dev/null +++ b/react-ui/src/pages/Model/components/GraphLegend/index.less @@ -0,0 +1,17 @@ +.graph-legend { + &__item { + margin-right: 20px; + color: @text-color; + font-size: @font-size-content; + + &:last-child { + margin-right: 0; + } + + &__name { + margin-left: 10px; + color: @text-color-secondary; + font-size: @font-size-content; + } + } +} diff --git a/react-ui/src/pages/Model/components/GraphLegend/index.tsx b/react-ui/src/pages/Model/components/GraphLegend/index.tsx new file mode 100644 index 00000000..5bf5076b --- /dev/null +++ b/react-ui/src/pages/Model/components/GraphLegend/index.tsx @@ -0,0 +1,73 @@ +import { Flex } from 'antd'; +import styles from './index.less'; + +type GraphLegendData = { + name: string; + color: string; + radius: number | string; + fill: boolean; +}; + +type GraphLegendProps = { + style?: React.CSSProperties; +}; + +function GraphLegend({ style }: GraphLegendProps) { + const legends: GraphLegendData[] = [ + { + name: '父模型', + color: 'linear-gradient(305deg,#43c9b1 0%,#93dfd1 100%)', + radius: 2, + fill: true, + }, + { + name: '当前模型', + color: 'linear-gradient(139.97deg,#72a1ff 0%,#1664ff 100%)', + radius: 2, + fill: true, + }, + { + name: '衍生模型', + color: 'linear-gradient(139.97deg,#72b4ff 0%,#169aff 100%)', + radius: 2, + fill: true, + }, + { + name: '训练数据集', + color: '#a5d878', + radius: '50%', + fill: true, + }, + { + name: '测试数据集', + color: '#d8b578', + radius: '50%', + fill: true, + }, + { + name: '项目', + color: 'linear-gradient(305deg,#8981ff 0%,#b3a9ff 100%)', + radius: 6, + fill: true, + }, + ]; + return ( + + {legends.map((item) => ( + +
+
{item.name}
+
+ ))} +
+ ); +} + +export default GraphLegend; diff --git a/react-ui/src/pages/Model/components/MetricsChart/index.less b/react-ui/src/pages/Model/components/MetricsChart/index.less new file mode 100644 index 00000000..8eed0b88 --- /dev/null +++ b/react-ui/src/pages/Model/components/MetricsChart/index.less @@ -0,0 +1,29 @@ +.metrics-chart { + width: calc((100% - 30px) / 3); + background-color: white; + + &__title { + display: flex; + align-items: center; + height: 36px; + padding-left: 15px; + color: @text-color; + font-size: 14px; + background-color: #ebf2ff; + + img { + width: 13px; + height: 13px; + margin-right: 12px; + } + } + + &__chart { + width: 100%; + height: 280px; + background: linear-gradient(180deg, #ffffff 0%, #fdfeff 100%); + border: 1px solid white; + border-radius: 0 0 10px 10px; + box-shadow: 0px 2px 12px rgba(180, 182, 191, 0.09); + } +} diff --git a/react-ui/src/pages/Model/components/MetricsChart/index.tsx b/react-ui/src/pages/Model/components/MetricsChart/index.tsx new file mode 100644 index 00000000..6529fa97 --- /dev/null +++ b/react-ui/src/pages/Model/components/MetricsChart/index.tsx @@ -0,0 +1,181 @@ +import * as echarts from 'echarts'; +import { useEffect, useMemo, useRef } from 'react'; +import styles from './index.less'; +import './tooltip.css'; + +const colors = [ + '#0D5EF8', + '#6AC21D', + '#F98E1B', + '#ECB934', + '#8A34EC', + '#FF1493', + '#FFFF00', + '#DAA520', + '#CD853F', + '#FF6347', + '#808080', + '#00BFFF', + '#008000', + '#00FFFF', + '#FFFACD', + '#FFA500', + '#FF4500', + '#800080', + '#FF1493', + '#000080', +]; + +const backgroundColor = new echarts.graphic.LinearGradient( + 0, + 0, + 0, + 1, + [ + { offset: 0, color: '#ffffff' }, + { offset: 1, color: '#fdfeff' }, + ], + false, +); + +function getTooltip(xTitle: string, xValue: number, yTitle: string, yValue: number) { + const str = `
+ Y: + X: +
${yTitle}
+
${yValue}
+
${xTitle}
+
${xValue}
+
`; + return str; +} + +export type MetricsChatData = { + name: string; + values: number[]; + version: string; + iters: number[]; +}; + +export type MetricsChartProps = { + name: string; + chartData: MetricsChatData[]; +}; + +function MetricsChart({ name, chartData }: MetricsChartProps) { + const chartRef = useRef(null); + const xAxisData = chartData[0]?.iters; + const seriesData = useMemo( + () => + chartData.map((item) => { + return { + name: item.version, + type: 'line' as const, + smooth: true, + data: item.values, + }; + }), + [chartData], + ); + + const options: echarts.EChartsOption = useMemo( + () => ({ + backgroundColor: backgroundColor, + title: { + show: false, + }, + tooltip: { + trigger: 'item', + padding: 10, + formatter: (params: any) => { + const { name: xTitle, data } = params; + return getTooltip('step', xTitle, name, data); + }, + }, + legend: { + bottom: 10, + icon: 'rect', + itemWidth: 10, + itemHeight: 10, + itemGap: 20, + textStyle: { + color: 'rgba(29, 29, 32, 0.75)', + fontSize: 12, + }, + }, + color: colors, + grid: { + left: '15', + right: '15', + top: '20', + bottom: '60', + containLabel: true, + }, + xAxis: { + type: 'category', + boundaryGap: true, + offset: 10, + data: xAxisData, + axisLabel: { + color: 'rgba(29, 29, 32, 0.75)', + fontSize: 12, + }, + axisTick: { + show: false, + }, + axisLine: { + lineStyle: { + color: '#eaeaea', + width: 1, + }, + }, + }, + yAxis: { + type: 'value', + axisLabel: { + color: 'rgba(29, 29, 32, 0.75)', + fontSize: 12, + margin: 15, + }, + axisLine: { + show: false, + }, + splitLine: { + lineStyle: { + color: '#e4e4e4', + width: 1, + type: 'dashed', + }, + }, + }, + series: seriesData, + }), + [name, seriesData, xAxisData], + ); + + useEffect(() => { + // 创建一个echarts实例,返回echarts实例 + const chart = echarts.init(chartRef.current); + + // 设置图表实例的配置项和数据 + chart.setOption(options); + + // 组件卸载 + return () => { + // 销毁实例 + chart.dispose(); + }; + }, [options]); + + return ( +
+
+ + {name} +
+
+
+ ); +} + +export default MetricsChart; diff --git a/react-ui/src/pages/Model/components/MetricsChart/tooltip.css b/react-ui/src/pages/Model/components/MetricsChart/tooltip.css new file mode 100644 index 00000000..1d714f4c --- /dev/null +++ b/react-ui/src/pages/Model/components/MetricsChart/tooltip.css @@ -0,0 +1,33 @@ +.metrics-tooltip { + width: 172px; + padding-left: 20px; + background-color: white; + font-size: 12px; +} + +.metrics-tooltip .y-text { + position: absolute; + left: 10px; + top: 10px; +} + +.metrics-tooltip .x-text { + position: absolute; + left: 10px; + top: 66px; +} + +.metrics-tooltip .title { + color: #575757; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 3px; +} + +.metrics-tooltip .value { + color: #1d1d20; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} \ No newline at end of file diff --git a/react-ui/src/pages/Model/components/ModelEvolution/index.less b/react-ui/src/pages/Model/components/ModelEvolution/index.less new file mode 100644 index 00000000..a089198b --- /dev/null +++ b/react-ui/src/pages/Model/components/ModelEvolution/index.less @@ -0,0 +1,16 @@ +.model-evolution { + width: 100%; + height: 100%; + padding: 0 @content-padding 20px; + overflow-x: hidden; + background: white; + border-radius: 0 0 10px 10px; + box-shadow: 0px 2px 12px rgba(180, 182, 191, 0.09); + + &__graph { + height: 100%; + background-color: @background-color; + background-image: url(@/assets/img/pipeline-canvas-bg.png); + background-size: 100% 100%; + } +} diff --git a/react-ui/src/pages/Model/components/ModelEvolution/index.tsx b/react-ui/src/pages/Model/components/ModelEvolution/index.tsx new file mode 100644 index 00000000..5fcc2fde --- /dev/null +++ b/react-ui/src/pages/Model/components/ModelEvolution/index.tsx @@ -0,0 +1,299 @@ +/* + * @Author: 赵伟 + * @Date: 2024-06-07 11:24:10 + * @Description: 模型演化 + */ + +import { useEffectWhen } from '@/hooks/useEffectWhen'; +import { getModelAtlasReq } from '@/services/dataset/index.js'; +import themes from '@/styles/theme.less'; +import { to } from '@/utils/promise'; +import G6, { G6GraphEvent, Graph, INode } from '@antv/g6'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import NodeTooltips from '../NodeTooltips'; +import styles from './index.less'; +import type { ModelDepsData, ProjectDependency, TrainDataset } from './utils'; +import { + NodeType, + getGraphData, + nodeFontSize, + nodeHeight, + nodeWidth, + normalizeTreeData, + traverseHierarchically, +} from './utils'; + +type modeModelEvolutionProps = { + resourceId: number; + identifier: string; + version?: string; + isActive: boolean; + onVersionChange: (version: string) => void; +}; + +let graph: Graph; +function ModelEvolution({ + resourceId, + identifier, + version, + isActive, + onVersionChange, +}: modeModelEvolutionProps) { + const graphRef = useRef(null); + const [showNodeTooltip, setShowNodeTooltip] = useState(false); + const [enterTooltip, setEnterTooltip] = useState(false); + const [nodeTooltipX, setNodeToolTipX] = useState(0); + const [nodeTooltipY, setNodeToolTipY] = useState(0); + const [isNodeTooltipLeft, setIsNodeTooltipLeft] = useState(true); + const [hoverNodeData, setHoverNodeData] = useState< + ModelDepsData | ProjectDependency | TrainDataset | undefined + >(undefined); + const apiData = useRef(undefined); // 接口返回的树形结构 + const hierarchyNodes = useRef([]); // 层级迭代树形结构,得到的节点列表 + const leaveNodeTimeout = useRef | null>(null); + const leaveTooltipTimeout = useRef | null>(null); + + useEffect(() => { + initGraph(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const changeSize = () => { + if (!graph || graph.get('destroyed')) return; + if (!graphRef.current) return; + graph.changeSize(graphRef.current.clientWidth, graphRef.current.clientHeight); + graph.fitView(); + }; + + window.addEventListener('resize', changeSize); + return () => { + window.removeEventListener('resize', changeSize); + }; + }, []); + + const getModelAtlas = useCallback(async () => { + // 请求失败或者版本不存在时,清除图形 + function clearGraphData() { + graph.data({ + nodes: [], + edges: [], + }); + graph.render(); + graph.fitView(); + } + + if (!resourceId || !identifier || !version) { + clearGraphData(); + return; + } + + const params = { + id: resourceId, + identifier, + version, + }; + const [res] = await to(getModelAtlasReq(params)); + if (res && res.data) { + const data = normalizeTreeData(res.data); + apiData.current = data; + hierarchyNodes.current = traverseHierarchically(data); + const graphData = getGraphData(data, hierarchyNodes.current); + + graph.data(graphData); + graph.render(); + graph.fitView(); + setShowNodeTooltip(false); + setEnterTooltip(false); + } else { + clearGraphData(); + } + }, [resourceId, identifier, version]); + + useEffectWhen(getModelAtlas, isActive, [resourceId, identifier, version]); + + // 初始化图 + const initGraph = () => { + graph = new G6.Graph({ + container: graphRef.current!, + width: graphRef.current!.clientWidth, + height: graphRef.current!.clientHeight, + fitView: true, + fitViewPadding: 100, + minZoom: 0.5, + maxZoom: 5, + defaultNode: { + type: 'rect', + size: [nodeWidth, nodeHeight], + anchorPoints: [ + [0, 0.5], + [1, 0.5], + [0.5, 0], + [0.5, 1], + ], + style: { + fill: themes['primaryColor'], + lineWidth: 0, + radius: 6, + cursor: 'pointer', + }, + labelCfg: { + position: 'center', + style: { + fill: '#ffffff', + fontSize: nodeFontSize, + textAlign: 'center', + cursor: 'pointer', + }, + }, + }, + defaultEdge: { + type: 'cubic-horizontal', + labelCfg: { + autoRotate: true, + }, + style: { + stroke: '#DEE0E5', + lineWidth: 1, + }, + }, + modes: { + default: ['drag-canvas', 'zoom-canvas'], + }, + }); + + bindEvents(); + }; + + // 绑定事件 + const bindEvents = () => { + graph.on('node:mouseenter', (e: G6GraphEvent) => { + // 清除延时关闭tooltip的定时器 + if (leaveNodeTimeout.current) { + clearTimeout(leaveNodeTimeout.current); + leaveNodeTimeout.current = null; + } + + const nodeItem = e.item; + graph.setItemState(nodeItem, 'hover', true); + + const model = nodeItem.getModel() as ModelDepsData; + const { x, y } = model; + const point = graph.getCanvasByPoint(x!, y!); + const zoom = graph.getZoom(); + + // 根据缩放,调整 tooltip 位置 + // const offsetX = (nodeWidth * zoom) / 4; + const offsetY = (nodeHeight * zoom) / 2; + // 25 是 `.model-evolution` 的 `padding-left` 值 + const tooltipX = point.x + 25 - 20; + // 20 是 `.model-evolution` 的 `padding-bottom` 值 + const tooltipY = graphRef.current!.clientHeight - point.y + offsetY + 20 + 10; + setNodeToolTipY(tooltipY); + + // 如果右边显示不下 + const canvasWidth = graphRef.current!.clientWidth; + // 300 是 NodeTool 的宽度,canvasWidth + 50 是 `.model-evolution` 的宽度 + if (tooltipX + 300 > canvasWidth + 50) { + setIsNodeTooltipLeft(false); + setNodeToolTipX(canvasWidth + 50 - (point.x + 25) - 20); + } else { + setNodeToolTipX(tooltipX); + setIsNodeTooltipLeft(true); + } + + setHoverNodeData(model); + setShowNodeTooltip(true); + }); + + graph.on('node:mouseleave', (e: G6GraphEvent) => { + const nodeItem = e.item; + graph.setItemState(nodeItem, 'hover', false); + leaveNodeTimeout.current = setTimeout(() => { + setShowNodeTooltip(false); + }, 100); + }); + + graph.on('node:click', (e: G6GraphEvent) => { + const nodeItem = e.item as INode; + const model = nodeItem.getModel() as ModelDepsData | ProjectDependency | TrainDataset; + const { model_type } = model; + if ( + model_type === NodeType.Project || + model_type === NodeType.TrainDataset || + model_type === NodeType.TestDataset || + !apiData.current || + !hierarchyNodes.current + ) { + return; + } + + setShowNodeTooltip(false); + setEnterTooltip(false); + toggleExpended(model.id); + const graphData = getGraphData(apiData.current, hierarchyNodes.current); + graph.data(graphData); + graph.render(); + graph.fitView(); + }); + + // 鼠标滚轮缩放时,隐藏 tooltip + graph.on('wheelzoom', () => { + setShowNodeTooltip(false); + setEnterTooltip(false); + }); + + // 开始拖拽画布时触发 + graph.on('DRAG_START', () => { + setShowNodeTooltip(false); + setEnterTooltip(false); + }); + }; + + // toggle 展开 + const toggleExpended = (id: string) => { + const nodes = hierarchyNodes.current; + for (const node of nodes) { + if (node.id === id) { + node.expanded = !node.expanded; + break; + } + } + }; + + const handleTooltipsMouseEnter = () => { + // 清除延时关闭tooltip的定时器 + if (leaveTooltipTimeout.current) { + clearTimeout(leaveTooltipTimeout.current); + leaveTooltipTimeout.current = null; + } + setEnterTooltip(true); + }; + + const handleTooltipsMouseLeave = () => { + leaveTooltipTimeout.current = setTimeout(() => { + setEnterTooltip(false); + }, 100); + }; + + return ( +
+
+ {(showNodeTooltip || enterTooltip) && ( + + )} +
+ ); +} + +export default ModelEvolution; diff --git a/react-ui/src/pages/Model/components/ModelEvolution/utils.tsx b/react-ui/src/pages/Model/components/ModelEvolution/utils.tsx new file mode 100644 index 00000000..dcb4c964 --- /dev/null +++ b/react-ui/src/pages/Model/components/ModelEvolution/utils.tsx @@ -0,0 +1,427 @@ +import { TrainTask } from '@/pages/Dataset/config'; +import { changePropertyName, fittingString } from '@/utils'; +import { EdgeConfig, GraphData, LayoutConfig, NodeConfig, TreeGraphData, Util } from '@antv/g6'; +// @ts-ignore +import Hierarchy from '@antv/hierarchy'; + +export const nodeWidth = 90; +export const nodeHeight = 40; +export const vGap = nodeHeight + 20; +export const hGap = nodeHeight + 20; +export const ellipseWidth = nodeWidth; +export const labelPadding = 30; +export const nodeFontSize = 8; +export const datasetHGap = 20; + +// 数据集节点 +const datasetNodes: NodeConfig[] = []; + +export enum NodeType { + Current = 'Current', // 当前模型 + Parent = 'Parent', // 父模型 + Children = 'Children', // 子模型 + Project = 'Project', // 项目 + TrainDataset = 'TrainDataset', // 训练数据集 + TestDataset = 'TestDataset', // 测试数据集 +} + +export type Rect = { + x: number; // 矩形中心的 x 坐标 + y: number; // 矩形中心的 y 坐标 + width: number; + height: number; +}; + +export interface TrainDataset extends NodeConfig { + repo_id: string; + name: string; + version: string; + identifier: string; + owner: string; + model_type: NodeType.TestDataset | NodeType.TrainDataset; +} + +export interface ProjectDependency extends NodeConfig { + url: string; + name: string; + branch: string; + model_type: NodeType.Project; +} + +export type ModelMeta = { + train_datasets?: TrainDataset[]; + test_datasets?: TrainDataset[]; + project_depency?: ProjectDependency; + train_task?: TrainTask; + name: string; + version: string; + model_source: string; + model_type: string; + create_time: string; + file_size: string; + is_public: boolean; +}; + +export interface ModelDepsAPIData { + repo_id: number; + model_name: string; + version: string; + workflow_id: number; + exp_ins_id: number; + model_type: NodeType.Children | NodeType.Current | NodeType.Parent; + model_meta: ModelMeta; + child_model_list: ModelDepsAPIData[]; + parent_model_vo?: ModelDepsAPIData; +} + +export interface ModelDepsData extends Omit, TreeGraphData { + children: ModelDepsData[]; + expanded: boolean; // 是否展开 + level: number; // 层级,从 0 开始 + datasetLen: number; // 数据集数量 +} + +// 规范化子数据 +export function normalizeChildren(data: ModelDepsData[]) { + if (Array.isArray(data)) { + data.forEach((item) => { + item.model_type = NodeType.Children; + item.expanded = false; + item.level = 0; + item.datasetLen = getDatasetLen( + item.model_meta.train_datasets, + item.model_meta.test_datasets, + ); + item.id = `$M_${item.repo_id}_${item.version}`; + item.label = getLabel(item); + item.style = getStyle(NodeType.Children); + normalizeChildren(item.children); + }); + } +} + +// 获取 label +export function getLabel(node: ModelDepsData | ModelDepsAPIData) { + return ( + fittingString(`${node.model_name ?? ''}`, nodeWidth - labelPadding, nodeFontSize) + + '\n' + + fittingString(`${node.version}`, nodeWidth - labelPadding, nodeFontSize) + ); +} + +// 获取数据集数量 +export function getDatasetLen(train?: TrainDataset[], test?: TrainDataset[]) { + return (train?.length || 0) + (test?.length || 0); +} + +// 获取 style +export function getStyle(model_type: NodeType) { + let fill = ''; + switch (model_type) { + case NodeType.Current: + fill = 'l(0) 0:#72a1ff 1:#1664ff'; + break; + case NodeType.Parent: + fill = 'l(0) 0:#93dfd1 1:#43c9b1'; + break; + case NodeType.Children: + fill = 'l(0) 0:#72b4ff 1:#169aff'; + break; + case NodeType.Project: + fill = 'l(0) 0:#b3a9ff 1:#8981ff'; + break; + case NodeType.TrainDataset: + fill = '#a5d878'; + break; + case NodeType.TestDataset: + fill = '#d8b578'; + break; + default: + break; + } + return { + fill, + }; +} + +// 将后台返回的数据转换成树形数据 +export function normalizeTreeData(apiData: ModelDepsAPIData): ModelDepsData { + // 将 children_models 转换成 children + let normalizedData = changePropertyName(apiData, { + child_model_list: 'children', + }) as ModelDepsData; + + // 设置当前模型的数据 + normalizedData.model_type = NodeType.Current; + normalizedData.id = `$M_${normalizedData.repo_id}_${normalizedData.version}`; + normalizedData.label = getLabel(normalizedData); + normalizedData.style = getStyle(NodeType.Current); + normalizedData.expanded = true; + normalizedData.datasetLen = getDatasetLen( + normalizedData.model_meta.train_datasets, + normalizedData.model_meta.test_datasets, + ); + normalizeChildren(normalizedData.children as ModelDepsData[]); + normalizedData.level = 0; + + // 将 parent_models 转换成树形结构 + let parent_model = normalizedData.parent_model_vo; + while (parent_model) { + const parent = parent_model; + normalizedData = { + ...parent, + expanded: false, + level: 0, + datasetLen: getDatasetLen(parent.model_meta.train_datasets, parent.model_meta.test_datasets), + model_type: NodeType.Parent, + id: `$M_${parent.repo_id}_${parent.version}`, + label: getLabel(parent), + style: getStyle(NodeType.Parent), + children: [ + { + ...normalizedData, + parent_model: null, + }, + ], + }; + parent_model = normalizedData.parent_model_vo; + } + return normalizedData; +} + +// 将树形数据,使用 Hierarchy 进行布局,计算出坐标,然后转换成 G6 的数据 +export function getGraphData(data: ModelDepsData, hierarchyNodes: ModelDepsData[]): GraphData { + const config = { + direction: 'LR', + getHeight: () => nodeHeight, + getWidth: () => nodeWidth, + getVGap: (node: NodeConfig) => { + const model = node as ModelDepsData; + const { model_type, expanded, model_meta } = model; + const { project_depency } = model_meta; + if (model_type === NodeType.Current || model_type === NodeType.Parent) { + return vGap / 2; + } + const selfGap = expanded && project_depency?.url ? nodeHeight + vGap : 0; + const nextNode = getSameHierarchyNextNode(model, hierarchyNodes); + if (!nextNode) { + return vGap / 2; + } + const nextGap = nextNode.expanded === true && nextNode.datasetLen > 0 ? nodeHeight + vGap : 0; + return (selfGap + nextGap + vGap) / 2; + }, + getHGap: (node: NodeConfig) => { + const model = node as ModelDepsData; + return ( + (getHierarchyWidth(model.level, hierarchyNodes) + + getHierarchyWidth(model.level + 1, hierarchyNodes) + + hGap) / + 2 + ); + }, + }; + + // 树形布局计算出坐标 + const treeLayoutData: LayoutConfig = Hierarchy['compactBox'](data, config); + + const nodes: NodeConfig[] = []; + const edges: EdgeConfig[] = []; + Util.traverseTree(treeLayoutData, (node: NodeConfig, parent: NodeConfig) => { + const data = node.data as ModelDepsData; + // 当前模型显示数据集和项目 + if (data.expanded === true) { + addDatasetDependency(data, node, nodes, edges); + addProjectDependency(data, node, nodes, edges); + } else if (data.model_type === NodeType.Children) { + // adjustDatasetPosition(node); + } + nodes.push({ + ...data, + x: node.x, + y: node.y, + }); + if (parent) { + edges.push({ + source: parent.id, + target: node.id, + }); + } + }); + return { nodes, edges }; +} + +// 将数据集转换成 G6 的数据 +const addDatasetDependency = ( + data: ModelDepsData, + currentNode: NodeConfig, + nodes: NodeConfig[], + edges: EdgeConfig[], +) => { + const { repo_id, model_meta } = data; + const { train_datasets, test_datasets } = model_meta; + train_datasets?.forEach((item) => { + if (!item.repo_id) { + item.repo_id = item.id; + } + item.id = `$DTrain_${repo_id}_${item.repo_id}_${item.version}`; + item.model_type = NodeType.TrainDataset; + item.style = getStyle(NodeType.TrainDataset); + }); + test_datasets?.forEach((item) => { + if (!item.repo_id) { + item.repo_id = item.id; + } + item.id = `$DTest_${repo_id}_${item.repo_id}_${item.version}`; + item.model_type = NodeType.TestDataset; + item.style = getStyle(NodeType.TestDataset); + }); + + datasetNodes.length = 0; + const len = getDatasetLen(train_datasets, test_datasets); + [...(train_datasets ?? []), ...(test_datasets ?? [])].forEach((item, index) => { + const node = { ...item }; + node.type = 'ellipse'; + node.size = [ellipseWidth, nodeHeight]; + node.label = + fittingString(node.name, ellipseWidth - labelPadding, nodeFontSize) + + '\n' + + fittingString(node.version, ellipseWidth - labelPadding, nodeFontSize); + + const half = len / 2 - 0.5; + node.x = currentNode.x! - (half - index) * (ellipseWidth + datasetHGap); + node.y = currentNode.y! - nodeHeight - vGap; + nodes.push(node); + datasetNodes.push(node); + edges.push({ + source: currentNode.id, + target: node.id, + sourceAnchor: 2, + targetAnchor: 3, + type: 'cubic-vertical', + }); + }); +}; + +// 将模型依赖数据转换成 G6 的数据 +const addProjectDependency = ( + data: ModelDepsData, + currentNode: NodeConfig, + nodes: NodeConfig[], + edges: EdgeConfig[], +) => { + const { repo_id, model_meta } = data; + const { project_depency } = model_meta; + if (project_depency?.url) { + const node = { ...project_depency }; + node.id = `$P_${repo_id}_${node.url}_${node.branch}`; + node.model_type = NodeType.Project; + node.type = 'rect'; + node.label = fittingString(node.name, nodeWidth - labelPadding, nodeFontSize); + node.style = getStyle(NodeType.Project); + node.style.radius = nodeHeight / 2; + node.x = currentNode.x; + node.y = currentNode.y! + nodeHeight + vGap; + + nodes.push(node); + edges.push({ + source: currentNode.id, + target: node.id, + sourceAnchor: 3, + targetAnchor: 2, + type: 'cubic-vertical', + }); + } +}; + +/* +// 判断两个矩形是否相交 +function isRectanglesOverlap(rect1: Rect, rect2: Rect) { + const a2x = rect1.x + rect1.width / 2; + const a2y = rect1.y + rect1.height / 2; + const b1x = rect2.x - rect2.width / 2; + const b1y = rect2.y - rect2.height / 2; + return b1y <= a2y && b1x <= a2x; +} + +// 判断子节点是否与数据集节点重叠 +function isChildrenOverlapDataset(nodes: NodeConfig[], childrenRect: Rect) { + for (const node of nodes) { + const rect = { x: node.x!, y: node.y!, width: nodeWidth, height: nodeHeight }; + if (isRectanglesOverlap(rect, childrenRect)) { + return childrenRect; + } + } + + return null; +} + +// 调整数据集位置 +function adjustDatasetPosition(node: NodeConfig) { + const nodeRect = { + x: node.x!, + y: node.y!, + width: nodeWidth, + height: nodeHeight, + }; + const overlapRect = isChildrenOverlapDataset(datasetNodes, nodeRect); + if (overlapRect) { + const adjustRect = { + x: overlapRect.x - nodeWidth - hGap / 2, + y: overlapRect.y, + width: overlapRect.width, + height: overlapRect.height, + }; + const lastNode = datasetNodes[datasetNodes.length - 1]; + const distance = lastNode.x! - adjustRect.x; + datasetNodes.forEach((item) => { + item.x = item.x! - distance; + }); + } +} +*/ + +// 层级遍历树结构 +export function traverseHierarchically(data: ModelDepsData | undefined): ModelDepsData[] { + if (!data) return []; + let level = 0; + data.level = level; + const result: ModelDepsData[] = [data]; + let index = 0; + + while (index < result.length) { + const item = result[index]; + if (item.children) { + item.children.forEach((child) => { + child.level = item.level + 1; + result.push(child); + }); + } + index++; + } + + return result; +} + +// 找到同层次的下一个节点 +export function getSameHierarchyNextNode(node: ModelDepsData, nodes: ModelDepsData[]) { + const index = nodes.findIndex((item) => item.id === node.id); + if (index >= 0 && index < nodes.length - 1) { + const nextNode = nodes[index + 1]; + if (nextNode.level === node.level) { + return nextNode; + } + } + return null; +} + +// 得到层级的宽度 +export function getHierarchyWidth(level: number, nodes: ModelDepsData[]) { + const hierarchyNodes = nodes + .filter((item) => item.level === level && item.expanded === true) + .sort((a, b) => b.datasetLen - a.datasetLen); + const first = hierarchyNodes[0]; + if (first) { + return Math.max(((first.datasetLen - 1) * (nodeWidth + datasetHGap)) / 2, 0); + } + return 0; +} diff --git a/react-ui/src/pages/Model/components/ModelMetrics/index.less b/react-ui/src/pages/Model/components/ModelMetrics/index.less new file mode 100644 index 00000000..bca3516c --- /dev/null +++ b/react-ui/src/pages/Model/components/ModelMetrics/index.less @@ -0,0 +1,38 @@ +.model-metrics { + &__table { + margin-top: 10px; + padding: 20px @content-padding 0; + background: white; + border-radius: 10px; + + :global { + .ant-table-container { + border: none !important; + } + .ant-table-thead { + .ant-table-cell { + background-color: rgb(247, 247, 247); + border-color: @border-color !important; + } + } + .ant-table-tbody { + .ant-table-cell { + border-right: none !important; + border-left: none !important; + } + } + .ant-table-column-title { + min-width: 0; + } + } + } + + &__chart { + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: center; + width: 100%; + margin-top: 10px; + } +} diff --git a/react-ui/src/pages/Model/components/ModelMetrics/index.tsx b/react-ui/src/pages/Model/components/ModelMetrics/index.tsx new file mode 100644 index 00000000..d6cdda8d --- /dev/null +++ b/react-ui/src/pages/Model/components/ModelMetrics/index.tsx @@ -0,0 +1,295 @@ +import SubAreaTitle from '@/components/SubAreaTitle'; +import TableColTitle from '@/components/TableColTitle'; +import { useCheck } from '@/hooks/useCheck'; +import { getModelPageVersionsReq, getModelVersionsMetricsReq } from '@/services/dataset'; +import { tableSorter } from '@/utils'; +import { VersionChangedMessage } from '@/utils/constant'; +import { to } from '@/utils/promise'; +import tableCellRender from '@/utils/table'; +import { Checkbox, Flex, Table, type TablePaginationConfig, type TableProps } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import MetricsChart, { MetricsChatData } from '../MetricsChart'; +import styles from './index.less'; + +enum MetricsType { + Train = 'train', // 训练 + Evaluate = 'evaluate', // 评估 +} + +type TableData = { + name: string; + metrics_names?: string[]; + metrics?: Record; + params_names?: string[]; + params?: Record; +}; + +type ModelMetricsProps = { + resourceId: number; + identifier: string; + owner: string; + version: string; // 当前版本 +}; + +function ModelMetrics({ resourceId, identifier, owner, version }: ModelMetricsProps) { + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + }); + const [total, setTotal] = useState(0); + const [tableData, setTableData] = useState([]); + const [chartData, setChartData] = useState | undefined>( + undefined, + ); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + // 获取所有的指标名称 + const allMetricsNames = useMemo(() => { + const first: TableData | undefined = tableData.find( + (item) => item.metrics_names && item.metrics_names.length > 0, + ); + return first?.metrics_names ?? []; + }, [tableData]); + const [ + selectedMetrics, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _setSelectedMetrics, + metricsChecked, + metricsIndeterminate, + checkAllMetrics, + isSingleMetricsChecked, + checkSingleMetrics, + ] = useCheck(allMetricsNames); + + // 新增,删除版本时,重置分页,然后刷新版本列表 + useEffect(() => { + const handleMessage = (e: MessageEvent) => { + const { type } = e.data; + if (type === VersionChangedMessage) { + setPagination({ + current: 1, + pageSize: 10, + }); + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, []); + + useEffect(() => { + // 获取模型版本列表,带有参数和指标数据 + const getModelPageVersions = async () => { + const params = { + page: pagination.current! - 1, + size: pagination.pageSize, + identifier: identifier, + owner: owner, + type: MetricsType.Train, + }; + const [res] = await to(getModelPageVersionsReq(params)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + setTableData(content); + setTotal(totalElements); + } + }; + + getModelPageVersions(); + }, [pagination, identifier, owner]); + + // 版本切换,自动勾选当前版本 + useEffect(() => { + const curRow = tableData.find((item) => item.name === version); + if (curRow && curRow.metrics_names && curRow.metrics_names.length > 0) { + setSelectedRowKeys((prev) => { + if (!prev.includes(version)) { + return [version, ...prev]; + } + return prev; + }); + } + }, [tableData, version]); + + useEffect(() => { + // 获取模型版本指标的图表数据 + const getModelVersionsMetrics = async () => { + const params = { + versions: selectedRowKeys, + metrics: selectedMetrics, + type: MetricsType.Train, + identifier: identifier, + repo_id: resourceId, + }; + const [res] = await to(getModelVersionsMetricsReq(params)); + if (res && res.data) { + setChartData(res.data); + } + }; + + if (selectedMetrics.length !== 0 && selectedRowKeys.length !== 0) { + getModelVersionsMetrics(); + } else { + setChartData(undefined); + } + }, [selectedMetrics, selectedRowKeys, identifier, resourceId]); + + // 分页切换 + const handleTableChange: TableProps['onChange'] = ( + pagination, + _filters, + _sorter, + { action }, + ) => { + if (action === 'paginate') { + setPagination(pagination); + } + }; + + // 行勾选 + const rowSelection: TableProps['rowSelection'] = { + type: 'checkbox', + fixed: 'left', + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys); + }, + getCheckboxProps: (record: TableData) => ({ + disabled: !record.metrics_names || record.metrics_names.length === 0, + }), + }; + + // 计算的表格数据 + const showTableData = useMemo(() => { + const index = tableData.findIndex((item) => item.name === version); + if (index !== -1) { + const rowData = tableData[index]; + const newTableData = tableData.filter((_, idx) => idx !== index); + return [rowData, ...newTableData]; + } + }, [version, tableData]); + + // 表头 + const columns: TableProps['columns'] = useMemo(() => { + const firstMetrics: TableData | undefined = tableData.find( + (item) => item.metrics_names && item.metrics_names.length > 0, + ); + const firstParams: TableData | undefined = tableData.find( + (item) => item.params_names && item.params_names.length > 0, + ); + const metricsNames = firstMetrics?.metrics_names ?? []; + const paramsNames = firstParams?.params_names ?? []; + return [ + { + title: '基本信息', + align: 'center', + children: [ + { + title: '版本号', + dataIndex: 'name', + key: 'name', + width: 180, + fixed: 'left', + align: 'center', + render: tableCellRender(false), + }, + ], + }, + { + title: `训练参数`, + align: 'center', + children: paramsNames.map((name) => ({ + title: , + dataIndex: ['params', name], + key: name, + width: 150, + align: 'center', + render: tableCellRender(true), + sorter: (a, b) => tableSorter(a.params?.[name], b.params?.[name]), + showSorterTooltip: false, + })), + }, + { + title: () => ( +
+ + 训练指标 +
+ ), + align: 'center', + children: metricsNames.map((name) => ({ + title: ( + + { + e.stopPropagation(); + checkSingleMetrics(name); + }} + onClick={(e) => e.stopPropagation()} + > + + + ), + dataIndex: ['metrics', name], + key: name, + width: 150, + align: 'center', + render: tableCellRender(true), + sorter: (a, b) => tableSorter(a.metrics?.[name], b.metrics?.[name]), + showSorterTooltip: false, + })), + }, + ]; + }, [ + tableData, + checkAllMetrics, + checkSingleMetrics, + isSingleMetricsChecked, + metricsChecked, + metricsIndeterminate, + ]); + + return ( +
+
+ +
`共${total}条`, + }} + onChange={handleTableChange} + rowKey="name" + tableLayout="fixed" + scroll={{ x: '100%' }} + /> + +
+ {chartData && + Object.keys(chartData).map((key) => ( + + ))} +
+ + ); +} + +export default ModelMetrics; diff --git a/react-ui/src/pages/Model/components/NodeTooltips/index.less b/react-ui/src/pages/Model/components/NodeTooltips/index.less new file mode 100644 index 00000000..245cbe5f --- /dev/null +++ b/react-ui/src/pages/Model/components/NodeTooltips/index.less @@ -0,0 +1,111 @@ +.node-tooltips { + position: absolute; + z-index: 10; + width: 300px; + padding: 10px; + background: white; + border: 1px solid #eaeaea; + border-radius: 4px; + box-shadow: 0px 3px 6px rgba(146, 146, 146, 0.09); + + &::after { + position: absolute; + bottom: -8px; /* 让三角形紧贴 div 底部 */ + left: 12px; /* 控制三角形相对 div 的位置 */ + width: 0; + height: 0; + border-top: 8px solid white; /* 主要颜色 */ + border-right: 8px solid transparent; + border-left: 8px solid transparent; + content: ''; + } + + &::before { + position: absolute; + bottom: -10px; /* 边框略大,形成描边效果 */ + left: 10px; /* 调整边框的偏移量,使其覆盖白色三角形 */ + width: 0; + height: 0; + border-top: 10px solid #eaeaea; /* 这是边框颜色 */ + border-right: 10px solid transparent; + border-left: 10px solid transparent; + content: ''; + } + + &__title { + margin: 10px 0; + color: @text-color; + font-weight: 500; + font-size: @font-size-content; + } + + &__row { + display: flex; + align-items: flex-start; + margin: 4px 0; + color: @text-color; + font-size: 14px; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 10px; + } + + &__title { + display: inline-block; + width: 100px; + color: @text-color-secondary; + text-align: right; + } + + &__value { + flex: 1; + min-width: 0; + color: @text-color; + font-weight: 500; + word-break: break-all; + } + + &__link { + flex: 1; + min-width: 0; + font-weight: 500; + word-break: break-all; + + &:hover { + text-decoration: underline @underline-color; + } + } + } +} + +.node-tooltips.node-tooltips--right { + &::after { + position: absolute; + right: 12px; /* 控制三角形相对 div 的位置 */ + bottom: -8px; /* 让三角形紧贴 div 底部 */ + left: auto; + width: 0; + height: 0; + border-top: 8px solid white; /* 主要颜色 */ + border-right: 8px solid transparent; + border-left: 8px solid transparent; + content: ''; + } + + &::before { + position: absolute; + right: 10px; /* 调整边框的偏移量,使其覆盖白色三角形 */ + bottom: -10px; /* 边框略大,形成描边效果 */ + left: auto; + width: 0; + height: 0; + border-top: 10px solid #eaeaea; /* 这是边框颜色 */ + border-right: 10px solid transparent; + border-left: 10px solid transparent; + content: ''; + } +} diff --git a/react-ui/src/pages/Model/components/NodeTooltips/index.tsx b/react-ui/src/pages/Model/components/NodeTooltips/index.tsx new file mode 100644 index 00000000..574a5065 --- /dev/null +++ b/react-ui/src/pages/Model/components/NodeTooltips/index.tsx @@ -0,0 +1,229 @@ +import { ResourceInfoTabKeys } from '@/pages/Dataset/components/ResourceInfo'; +import { getGitUrl } from '@/utils'; +import { formatDate } from '@/utils/date'; +import { useNavigate } from '@umijs/max'; +import classNames from 'classnames'; +import { ModelDepsData, NodeType, ProjectDependency, TrainDataset } from '../ModelEvolution/utils'; +import styles from './index.less'; + +type ModelInfoProps = { + resourceId: number; + data: ModelDepsData; + onVersionChange: (version: string) => void; +}; + +function ModelInfo({ resourceId, data, onVersionChange }: ModelInfoProps) { + const navigate = useNavigate(); + + const gotoExperimentPage = () => { + if (data.model_meta.train_task?.ins_id) { + const { origin } = location; + const url = `${origin}/pipeline/experiment/instance/${data.model_meta.train_task.workflow_id}/${data.model_meta.train_task.ins_id}`; + window.open(url, '_blank'); + } + }; + + const gotoModelPage = () => { + if (data.model_type === NodeType.Current) { + return; + } + if (data.repo_id === resourceId) { + onVersionChange?.(data.version); + } else { + const path = `/dataset/model/info/${data.repo_id}?tab=${ResourceInfoTabKeys.Evolution}&version=${data.version}&name=${data.model_name}&owner=${data.owner}&identifier=${data.identifier}`; + navigate(path); + } + }; + + return ( + <> +
模型信息
+
+
+ 模型名称: + {data.model_type === NodeType.Current ? ( + {data.model_name || '--'} + ) : ( + + )} +
+
+ 模型版本: + {data.version || '--'} +
+
+ 模型框架: + + {data.model_meta.model_type || '--'} + +
+ {/*
+ 模型大小: + + {data.model_meta.file_size || '--'} + +
*/} +
+ 创建时间: + + {formatDate(data.model_meta.create_time || '--')} + +
+
+ 模型权限: + + {data.model_meta.is_public ? '公开' : '私有'} + +
+
+
训练相关信息
+
+
+ 训练任务: + +
+
+ + ); +} + +function DatasetInfo({ data }: { data: TrainDataset }) { + const gotoDatasetPage = () => { + const { origin } = location; + const url = `${origin}/dataset/dataset/info/${data.repo_id}?tab=${ResourceInfoTabKeys.Version}&version=${data.version}&name=${data.name}&owner=${data.owner}&identifier=${data.identifier}`; + window.open(url, '_blank'); + }; + + return ( + <> +
数据集信息
+
+
+ 数据集名称: + +
+
+ 数据集版本: + {data.version || '--'} +
+
+ + ); +} + +function ProjectInfo({ data }: { data: ProjectDependency }) { + const gotoProjectPage = () => { + const { url, branch } = data; + const projectUrl = getGitUrl(url, branch); + window.open(projectUrl, '_blank'); + }; + + return ( + <> +
项目信息
+
+
+ 项目名称: + +
+
+ 项目分支: + {data.branch || '--'} +
+
+ 项目地址: + {data.url || '--'} +
+
+ + ); +} + +type ValueLinkProps = { + value: string | undefined; + onClick?: () => void; + className?: string; + nullClassName?: string; +}; + +const ValueLink = ({ value, onClick, className, nullClassName }: ValueLinkProps) => { + return value ? ( + + {value} + + ) : ( + -- + ); +}; + +type NodeTooltipsProps = { + resourceId: number; + data: ModelDepsData | ProjectDependency | TrainDataset; + x: number; + y: number; + isLeft: boolean; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onVersionChange: (version: string) => void; +}; + +function NodeTooltips({ + resourceId, + data, + x, + y, + isLeft, + onMouseEnter, + onMouseLeave, + onVersionChange, +}: NodeTooltipsProps) { + if (!data) return null; + let Component = null; + const { model_type } = data; + if (model_type === NodeType.TestDataset || model_type === NodeType.TrainDataset) { + Component = ; + } else if (model_type === NodeType.Project) { + Component = ; + } else if ( + model_type === NodeType.Children || + model_type === NodeType.Parent || + model_type === NodeType.Current + ) { + Component = ; + } + const style = isLeft + ? { left: `${x}px`, bottom: `${y}px` } + : { right: `${x}px`, bottom: `${y}px` }; + return ( +
+ {Component} +
+ ); +} + +export default NodeTooltips; diff --git a/react-ui/src/pages/Model/index.tsx b/react-ui/src/pages/Model/index.tsx new file mode 100644 index 00000000..d71c3ec4 --- /dev/null +++ b/react-ui/src/pages/Model/index.tsx @@ -0,0 +1,7 @@ +import ResourcePage from '@/pages/Dataset/components/ResourcePage'; +import { ResourceType } from '@/pages/Dataset/config'; + +const ModelPage = () => { + return ; +}; +export default ModelPage; diff --git a/react-ui/src/pages/Model/intro.tsx b/react-ui/src/pages/Model/intro.tsx new file mode 100644 index 00000000..cc6e317a --- /dev/null +++ b/react-ui/src/pages/Model/intro.tsx @@ -0,0 +1,8 @@ +import ResourceInfo from '@/pages/Dataset/components/ResourceInfo'; +import { ResourceType } from '@/pages/Dataset/config'; + +function ModelInfo() { + return ; +} + +export default ModelInfo; diff --git a/react-ui/src/pages/ModelDeployment/CreateService/index.less b/react-ui/src/pages/ModelDeployment/CreateService/index.less new file mode 100644 index 00000000..f098861f --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/CreateService/index.less @@ -0,0 +1,19 @@ +.model-deployment-create { + height: 100%; + + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 30px 30px 10px; + overflow: auto; + color: @text-color; + font-size: @font-size-content; + background-color: white; + border-radius: 10px; + + &__type { + color: @text-color; + font-size: @font-size-input-lg; + } + } +} diff --git a/react-ui/src/pages/ModelDeployment/CreateService/index.tsx b/react-ui/src/pages/ModelDeployment/CreateService/index.tsx new file mode 100644 index 00000000..93026dde --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/CreateService/index.tsx @@ -0,0 +1,186 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 创建推理服务 + */ + +import PageTitle from '@/components/PageTitle'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { CommonTabKeys, serviceTypeOptions } from '@/enums'; +import { createServiceReq, getServiceInfoReq, updateServiceReq } from '@/services/modelDeployment'; +import { ServiceCreatedMessage } from '@/utils/constant'; +import { to } from '@/utils/promise'; +import { useNavigate, useParams } from '@umijs/max'; +import { App, Button, Col, Form, Input, Row, Select } from 'antd'; +import { useEffect } from 'react'; +import styles from './index.less'; + +// 表单数据 +export type FormData = { + service_name: string; // 服务名称 + service_type: string; // 服务类型 + description: string; // 描述 +}; + +function CreateService() { + const navigate = useNavigate(); + const [form] = Form.useForm(); + const { message } = App.useApp(); + const params = useParams(); + const serviceId = params.serviceId; + + useEffect(() => { + // 获取服务详情 + const getServiceInfo = async () => { + const [res] = await to(getServiceInfoReq(serviceId)); + if (res && res.data) { + const { service_type, service_name, description } = res.data; + form.setFieldsValue({ + service_type, + service_name, + description, + }); + } + }; + + if (serviceId) { + getServiceInfo(); + } + }, [serviceId, form]); + + // 创建、更新服务 + const createService = async (formData: FormData) => { + const request = serviceId ? updateServiceReq : createServiceReq; + const params = serviceId + ? { + id: serviceId, + ...formData, + } + : formData; + const [res] = await to(request(params)); + if (res && res.data) { + message.success('操作成功'); + navigate(-1); + if (!serviceId) { + setTimeout(() => { + window.postMessage({ type: ServiceCreatedMessage, payload: res.data.id }); + }, 500); + } + } + }; + + // 提交 + const handleSubmit = (values: FormData) => { + createService(values); + }; + + // 取消 + const cancel = () => { + navigate(-1); + }; + + const disabled = !!serviceId; + const title = serviceId ? '编辑推理服务' : '创建推理服务'; + + return ( +
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* + + + + + + */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }, index) => ( + + ['env_variables', i, 'key'])} + rules={[ + { + validator: (_, value) => { + if (!value) { + return Promise.reject(new Error('请输入变量名')); + } + if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value)) { + return Promise.reject( + new Error( + '变量名只支持字母、数字、下划线、中横线并且必须以字母或下划线开头', + ), + ); + } + // 判断不能重名 + const list = form + .getFieldValue('env_variables') + .filter( + (item: FormEnvVariable | undefined) => + item !== undefined && item !== null, + ); + + const names = list.map((item: FormEnvVariable) => item.key); + if (new Set(names).size !== names.length) { + return Promise.reject(new Error('名称不能重复')); + } + return Promise.resolve(); + }, + }, + ]} + > + + + = + + + + + + {index === fields.length - 1 && ( + + )} + + + ))} + {fields.length === 0 && ( + + + + )} + + )} + + + + + + + + + + + + + + ); +} + +export default CreateServiceVersion; diff --git a/react-ui/src/pages/ModelDeployment/List/index.less b/react-ui/src/pages/ModelDeployment/List/index.less new file mode 100644 index 00000000..9e521f70 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/List/index.less @@ -0,0 +1,21 @@ +.model-deployment { + height: 100%; + &__content { + height: calc(100% - 60px); + margin-top: 10px; + padding: 20px 30px 0; + background-color: white; + border-radius: 10px; + + &__filter { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__table { + height: calc(100% - 32px - 28px); + margin-top: 28px; + } + } +} diff --git a/react-ui/src/pages/ModelDeployment/List/index.tsx b/react-ui/src/pages/ModelDeployment/List/index.tsx new file mode 100644 index 00000000..9d2ce096 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/List/index.tsx @@ -0,0 +1,339 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 模型部署服务列表 + */ +import KFIcon from '@/components/KFIcon'; +import PageTitle from '@/components/PageTitle'; +import { serviceTypeOptions } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { deleteServiceReq, getServiceListReq } from '@/services/modelDeployment'; +import themes from '@/styles/theme.less'; +import { ServiceCreatedMessage } from '@/utils/constant'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { modalConfirm } from '@/utils/ui'; +import { useNavigate } from '@umijs/max'; +import { + App, + Button, + ConfigProvider, + Input, + Select, + Table, + type TablePaginationConfig, + type TableProps, +} from 'antd'; +import { type SearchProps } from 'antd/es/input'; +import classNames from 'classnames'; +import { useCallback, useEffect, useState } from 'react'; +import { CreateServiceVersionFrom, ServiceData, ServiceOperationType } from '../types'; +import styles from './index.less'; + +const allServiceTypeOptions = [{ label: '全部', value: '' }, ...serviceTypeOptions]; + +function ModelDeployment() { + const navigate = useNavigate(); + const { message } = App.useApp(); + const [cacheState, setCacheState] = useCacheState(); + const [serviceType, setServiceType] = useState(cacheState?.serviceType ?? ''); + const [searchText, setSearchText] = useState(cacheState?.searchText); + const [inputText, setInputText] = useState(cacheState?.searchText); + const [tableData, setTableData] = useState([]); + const [total, setTotal] = useState(0); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 10, + }, + ); + + // 获取模型部署服务列表 + const getServiceList = useCallback(async () => { + const params: Record = { + page: pagination.current! - 1, + size: pagination.pageSize, + service_name: searchText || undefined, + service_type: serviceType, + }; + const [res] = await to(getServiceListReq(params)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + setTableData(content); + setTotal(totalElements); + } + }, [pagination, searchText, serviceType]); + + // 去创建服务版本 + const gotoCreateServiceVersion = useCallback( + (serviceId: number) => { + SessionStorage.setItem( + SessionStorage.serviceVersionInfoKey, + { + operationType: ServiceOperationType.Create, + lastPage: CreateServiceVersionFrom.CreateService, + }, + true, + ); + + navigate(`serviceInfo/${serviceId}/createVersion`); + }, + [navigate], + ); + + // 获取模型部署服务列表 + useEffect(() => { + getServiceList(); + }, [getServiceList]); + + // 接收创建服务成功的消息 + useEffect(() => { + const handleMessage = (e: MessageEvent) => { + const { type, payload } = e.data; + if (type === ServiceCreatedMessage) { + modalConfirm({ + title: '创建服务成功', + content: '是否创建服务版本?', + isDelete: false, + cancelText: '稍后创建', + onOk: () => { + gotoCreateServiceVersion(payload); + }, + }); + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [gotoCreateServiceVersion]); + + // 删除模型部署 + const deleteService = async (record: ServiceData) => { + const [res] = await to(deleteServiceReq(record.id)); + if (res) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: tableData.length === 1 ? Math.max(1, prev.current! - 1) : prev.current, + }; + }); + } + }; + + // 搜索 + const onSearch: SearchProps['onSearch'] = (value) => { + setSearchText(value); + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + // 处理删除 + const handleServiceDelete = (record: ServiceData) => { + modalConfirm({ + title: '删除后,该服务将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteService(record); + }, + }); + }; + + // 创建、更新服务 + const createService = (record?: ServiceData) => { + setCacheState({ + pagination, + searchText, + serviceType: serviceType, + }); + + if (record) { + navigate(`editService/${record.id}`); + } else { + navigate('createService'); + } + }; + + // 查看详情 + const toDetail = (record: ServiceData) => { + setCacheState({ + pagination, + searchText, + serviceType: serviceType, + }); + + navigate(`serviceInfo/${record.id}`); + }; + + // 分页切换 + const handleTableChange: TableProps['onChange'] = ( + pagination, + _filters, + _sorter, + { action }, + ) => { + if (action === 'paginate') { + setPagination(pagination); + } + // console.log(pagination, filters, sorter, action); + }; + + const columns: TableProps['columns'] = [ + { + title: '序号', + dataIndex: 'index', + key: 'index', + width: '20%', + render: tableCellRender(false, TableCellValueType.Index, { + page: pagination.current! - 1, + pageSize: pagination.pageSize!, + }), + }, + { + title: '服务名称', + dataIndex: 'service_name', + key: 'service_name', + width: '20%', + render: tableCellRender(false, TableCellValueType.Link, { + onClick: toDetail, + }), + }, + { + title: '服务类型', + dataIndex: 'service_type_name', + key: 'service_type_name', + width: '20%', + render: tableCellRender(), + }, + { + title: '版本数量', + dataIndex: 'version_count', + key: 'version_count', + width: '20%', + render: tableCellRender(), + }, + { + title: '服务描述', + dataIndex: 'description', + key: 'description', + render: tableCellRender(), + width: '20%', + }, + { + title: '更新时间', + dataIndex: 'update_time', + key: 'update_time', + width: '20%', + render: tableCellRender(false, TableCellValueType.Date), + }, + { + title: '操作', + dataIndex: 'operation', + width: 300, + key: 'operation', + render: (_: any, record: ServiceData) => ( +
+ + + + + +
+ ), + }, + ]; + + return ( +
+ +
+
+ setInputText(e.target.value)} + style={{ width: 300 }} + value={inputText} + allowClear + /> + + +
+
+
`共${total}条`, + }} + onChange={handleTableChange} + rowKey="id" + /> + + + + ); +} + +export default ModelDeployment; diff --git a/react-ui/src/pages/ModelDeployment/ServiceInfo/index.less b/react-ui/src/pages/ModelDeployment/ServiceInfo/index.less new file mode 100644 index 00000000..13c224f4 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/ServiceInfo/index.less @@ -0,0 +1,25 @@ +.service-info { + height: 100%; + &__content { + display: flex; + flex-direction: column; + height: calc(100% - 60px); + margin-top: 10px; + padding: 20px 30px 0; + background-color: white; + border-radius: 10px; + + &__filter { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + } + + &__table { + flex: 1; + min-height: 0; + margin-top: 24px; + } + } +} diff --git a/react-ui/src/pages/ModelDeployment/ServiceInfo/index.tsx b/react-ui/src/pages/ModelDeployment/ServiceInfo/index.tsx new file mode 100644 index 00000000..fae1e3be --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/ServiceInfo/index.tsx @@ -0,0 +1,488 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 模型部署列表 + */ +import BasicInfo from '@/components/BasicInfo'; +import KFIcon from '@/components/KFIcon'; +import PageTitle from '@/components/PageTitle'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { ServiceRunStatus, serviceStatusOptions } from '@/enums'; +import { useCacheState } from '@/hooks/useCacheState'; +import { useSystemResource } from '@/hooks/useComputingResource'; +import { ModelData } from '@/pages/Dataset/config'; +import { + deleteServiceVersionReq, + getServiceInfoReq, + getServiceVersionsReq, + stopServiceVersionReq, +} from '@/services/modelDeployment'; +import themes from '@/styles/theme.less'; +import { formatDate } from '@/utils/date'; +import { formatModel } from '@/utils/format'; +import { openAntdModal } from '@/utils/modal'; +import { to } from '@/utils/promise'; +import SessionStorage from '@/utils/sessionStorage'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { modalConfirm } from '@/utils/ui'; +import { useNavigate, useParams } from '@umijs/max'; +import { + App, + Button, + ConfigProvider, + Input, + Select, + Table, + type TablePaginationConfig, + type TableProps, +} from 'antd'; +import { type SearchProps } from 'antd/es/input'; +import classNames from 'classnames'; +import { useCallback, useEffect, useState } from 'react'; +import ServiceRunStatusCell from '../components/ModelDeployStatusCell'; +import VersionCompareModal from '../components/VersionCompareModal'; +import { + CreateServiceVersionFrom, + ServiceData, + ServiceOperationType, + ServiceVersionData, +} from '../types'; +import styles from './index.less'; + +const allServiceStatusOptions = [{ label: '全部', value: '' }, ...serviceStatusOptions]; + +function ServiceInfo() { + const navigate = useNavigate(); + const { message } = App.useApp(); + const [cacheState, setCacheState] = useCacheState(); + const [serviceStatus, setServiceStatus] = useState(cacheState?.serviceStatus ?? ''); + const [searchText, setSearchText] = useState(cacheState?.searchText); + const [inputText, setInputText] = useState(cacheState?.searchText); + const [tableData, setTableData] = useState([]); + const [total, setTotal] = useState(0); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 10, + }, + ); + const params = useParams(); + const serviceId = params.serviceId; + const [serviceInfo, setServiceInfo] = useState(undefined); + const basicInfo = [ + { + label: '服务名称', + value: serviceInfo?.service_name, + }, + { + label: '服务描述', + value: serviceInfo?.description, + }, + { + label: '版本数量', + value: serviceInfo?.version_count, + }, + { + label: '创建时间', + value: serviceInfo?.create_time, + format: formatDate, + }, + ]; + const getResourceDescription = useSystemResource(); + + // 获取服务详情 + const getServiceInfo = useCallback(async () => { + const [res] = await to(getServiceInfoReq(serviceId)); + if (res && res.data) { + setServiceInfo(res.data); + } + }, [serviceId]); + + // 获取服务版本列表 + const getServiceVersions = useCallback(async () => { + const params: Record = { + page: pagination.current! - 1, + size: pagination.pageSize, + version: searchText || undefined, + run_state: serviceStatus, + service_id: serviceId, + }; + const [res] = await to(getServiceVersionsReq(params)); + if (res && res.data) { + const { content = [], totalElements = 0 } = res.data; + content.forEach((item: ServiceVersionData) => { + if (item.model && !item.model.showValue) { + item.model.showValue = `${item.model.name}:${item.model.version}`; + } + }); + setTableData(content); + setTotal(totalElements); + } + }, [pagination, serviceStatus, searchText, serviceId]); + + useEffect(() => { + getServiceInfo(); + }, [getServiceInfo]); + + useEffect(() => { + getServiceVersions(); + }, [getServiceVersions]); + + // 删除模型部署 + const deleteServiceVersion = async (record: ServiceVersionData) => { + const [res] = await to(deleteServiceVersionReq(record.id)); + if (res) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: tableData.length === 1 ? Math.max(1, prev.current! - 1) : prev.current, + }; + }); + getServiceInfo(); + } + }; + + // 停止模型部署 + const stopServiceVersion = async (record: ServiceVersionData) => { + const [res] = await to(stopServiceVersionReq(record.id)); + if (res) { + message.success('操作成功'); + getServiceVersions(); + } + }; + + // 搜索 + const onSearch: SearchProps['onSearch'] = (value) => { + setSearchText(value); + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + // 处理删除 + const handleServiceVersionDelete = (record: ServiceVersionData) => { + modalConfirm({ + title: '删除后,该服务版本将不可恢复', + content: '是否确认删除?', + onOk: () => { + deleteServiceVersion(record); + }, + }); + }; + + // 处理停止 + const handleServiceVersionStop = async (record: ServiceVersionData) => { + modalConfirm({ + title: '是否确认停止该服务?', + isDelete: false, + onOk: () => { + stopServiceVersion(record); + }, + }); + }; + + // 创建、更新、重启服务版本 + const createServiceVersion = (type: ServiceOperationType, record?: ServiceVersionData) => { + SessionStorage.setItem( + SessionStorage.serviceVersionInfoKey, + { + ...record, + operationType: type, + lastPage: CreateServiceVersionFrom.ServiceInfo, + }, + true, + ); + + setCacheState({ + pagination, + searchText, + serviceStatus: serviceStatus, + }); + + if (type === ServiceOperationType.Update) { + navigate('updateVersion'); + } else if (type === ServiceOperationType.Restart) { + navigate('restartVersion'); + } else { + navigate('createVersion'); + } + }; + + // 查看详情 + const toDetail = (record: ServiceVersionData) => { + setCacheState({ + pagination, + searchText, + serviceStatus: serviceStatus, + }); + + navigate(`versionInfo/${record.id}`); + }; + + // 分页切换 + const handleTableChange: TableProps['onChange'] = ( + pagination, + _filters, + _sorter, + { action }, + ) => { + if (action === 'paginate') { + setPagination(pagination); + } + }; + + // 版本对比 + const handleVersionCompare = () => { + if (selectedRowKeys.length !== 2) { + message.error('请选择两个版本进行对比'); + return; + } + + openAntdModal(VersionCompareModal, { + version1: selectedRowKeys[0] as string, + version2: selectedRowKeys[1] as string, + }); + }; + + // 选择行 + const rowSelection: TableProps['rowSelection'] = { + type: 'checkbox', + columnWidth: 48, + fixed: 'left', + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys); + }, + }; + + // 去模型 + const gotoModel = (record: ServiceVersionData, e: React.MouseEvent) => { + e.stopPropagation(); + + const model = record.model as any as ModelData; + const link = formatModel(model)?.link; + if (link) { + setCacheState({ + pagination, + }); + navigate(link); + } + }; + + const columns: TableProps['columns'] = [ + { + title: '序号', + dataIndex: 'index', + key: 'index', + width: '20%', + render: tableCellRender(false, TableCellValueType.Index, { + page: pagination.current! - 1, + pageSize: pagination.pageSize!, + }), + }, + { + title: '服务版本', + dataIndex: 'version', + key: 'version', + width: '20%', + render: tableCellRender(), + }, + { + title: '模型版本', + dataIndex: ['model', 'showValue'], + key: 'model', + width: '20%', + render: tableCellRender(true, TableCellValueType.Link, { + onClick: gotoModel, + }), + }, + { + title: '镜像版本', + dataIndex: ['image', 'showValue'], + key: 'image', + width: '20%', + render: tableCellRender(true), + }, + { + title: '副本数量', + dataIndex: 'replicas', + key: 'replicas', + render: tableCellRender(), + width: '20%', + }, + { + title: '资源规格', + dataIndex: 'computing_resource_id', + key: 'computing_resource_id', + width: '20%', + render: tableCellRender(true, TableCellValueType.Custom, { + format: getResourceDescription, + }), + }, + { + title: '状态', + dataIndex: 'run_state', + key: 'run_state', + width: '20%', + render: ServiceRunStatusCell, + }, + { + title: '操作', + dataIndex: 'operation', + width: 320, + key: 'operation', + render: (_: any, record: ServiceVersionData) => ( +
+ + + {(record.run_state === ServiceRunStatus.Failed || + record.run_state === ServiceRunStatus.Stopped) && ( + + )} + {(record.run_state === ServiceRunStatus.Running || + record.run_state === ServiceRunStatus.Init || + record.run_state === ServiceRunStatus.Pending) && ( + + )} + + + + +
+ ), + }, + ]; + + return ( +
+ +
+ + + +
+ setInputText(e.target.value)} + style={{ width: 300 }} + value={inputText} + allowClear + /> + + + + +
+
+
`共${total}条`, + }} + onChange={handleTableChange} + rowSelection={rowSelection} + rowKey="id" + /> + + + + ); +} + +export default ServiceInfo; diff --git a/react-ui/src/pages/ModelDeployment/VersionInfo/index.less b/react-ui/src/pages/ModelDeployment/VersionInfo/index.less new file mode 100644 index 00000000..3c0a9ead --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/VersionInfo/index.less @@ -0,0 +1,37 @@ +.service-version-info { + height: 100%; + + &__content { + display: flex; + flex-direction: column; + height: calc(100% - 60px); + margin-top: 10px; + padding: 10px 30px 0; + background-color: white; + border-radius: 10px; + + &__tabs { + flex: 1; + min-height: 0; + padding-bottom: 10px; + + :global { + .ant-tabs { + height: 100%; + + .ant-tabs-nav { + margin-bottom: 10px; + } + + .ant-tabs-content { + height: 100%; + + .ant-tabs-tabpane { + height: 100%; + } + } + } + } + } + } +} diff --git a/react-ui/src/pages/ModelDeployment/VersionInfo/index.tsx b/react-ui/src/pages/ModelDeployment/VersionInfo/index.tsx new file mode 100644 index 00000000..23d836f5 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/VersionInfo/index.tsx @@ -0,0 +1,94 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-16 13:58:08 + * @Description: 服务版本详情 + */ +import IframePage from '@/components/IFramePage'; +import KFIcon from '@/components/KFIcon'; +import PageTitle from '@/components/PageTitle'; +import { ServiceRunStatus } from '@/enums'; +import { getServiceVersionInfoReq } from '@/services/modelDeployment'; +import { to } from '@/utils/promise'; +import { useParams } from '@umijs/max'; +import { Tabs } from 'antd'; +import { useEffect, useState } from 'react'; +import ServerLog from '../components/ServerLog'; +import VersionBasicInfo from '../components/VersionBasicInfo'; +import { ServiceVersionData } from '../types'; +import styles from './index.less'; + +export enum ModelDeploymentTabKey { + Basic = 'Basic', // 基本信息 + Predict = 'Predict', // 预测 + Guide = 'Guide', // 调用指南 + Log = 'Log', // 服务日志 +} + +function ServiceVersionInfo() { + const [versionInfo, setVersionInfo] = useState(undefined); + const params = useParams(); + const id = params.id; + + useEffect(() => { + // 获取服务版本详情 + const getServiceVersionInfo = async () => { + const [res] = await to(getServiceVersionInfoReq(id)); + if (res && res.data) { + setVersionInfo(res.data); + } + }; + + if (id) { + getServiceVersionInfo(); + } + }, [id]); + + const tabItems = [ + { + key: ModelDeploymentTabKey.Basic, + label: '基本信息', + icon: , + children: , + }, + ]; + + if (versionInfo?.run_state === ServiceRunStatus.Running) { + if (versionInfo?.page_path) { + tabItems.push({ + key: ModelDeploymentTabKey.Predict, + label: '预测', + icon: , + children: , + }); + } + + if (versionInfo?.doc_path) { + tabItems.push({ + key: ModelDeploymentTabKey.Guide, + label: '调用指南', + icon: , + children: , + }); + } + } + + tabItems.push({ + key: ModelDeploymentTabKey.Log, + label: '服务日志', + icon: , + children: , + }); + + return ( +
+ +
+
+ +
+
+
+ ); +} + +export default ServiceVersionInfo; diff --git a/react-ui/src/pages/ModelDeployment/components/ModelDeployStatusCell/index.less b/react-ui/src/pages/ModelDeployment/components/ModelDeployStatusCell/index.less new file mode 100644 index 00000000..bd0b3e52 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/ModelDeployStatusCell/index.less @@ -0,0 +1,19 @@ +.model-deployment-status-cell { + color: @text-color; + + &--running { + color: @primary-color; + } + + &--stopped { + color: @abort-color; + } + + &--error { + color: @error-color; + } + + &--pending { + color: @warning-color; + } +} diff --git a/react-ui/src/pages/ModelDeployment/components/ModelDeployStatusCell/index.tsx b/react-ui/src/pages/ModelDeployment/components/ModelDeployStatusCell/index.tsx new file mode 100644 index 00000000..e11f8e43 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/ModelDeployStatusCell/index.tsx @@ -0,0 +1,44 @@ +/* + * @Author: 赵伟 + * @Date: 2024-04-18 18:35:41 + * @Description: 服务运行状态 + */ +import { ServiceRunStatus } from '@/enums'; +import styles from './index.less'; + +export type ServiceRunStatusInfo = { + text: string; + classname: string; +}; + +export const statusInfo: Record = { + [ServiceRunStatus.Init]: { + text: '启动中', + classname: styles['model-deployment-status-cell'], + }, + [ServiceRunStatus.Running]: { + classname: styles['model-deployment-status-cell--running'], + text: '运行中', + }, + [ServiceRunStatus.Stopped]: { + classname: styles['model-deployment-status-cell--stopped'], + text: '已停止', + }, + [ServiceRunStatus.Failed]: { + classname: styles['model-deployment-status-cell--error'], + text: '失败', + }, + [ServiceRunStatus.Pending]: { + classname: styles['model-deployment-status-cell--pending'], + text: '挂起中', + }, +}; + +function ServiceRunStatusCell(status?: ServiceRunStatus | null) { + if (status === null || status === undefined || !statusInfo[status]) { + return --; + } + return {statusInfo[status].text}; +} + +export default ServiceRunStatusCell; diff --git a/react-ui/src/pages/ModelDeployment/components/ServerLog/index.less b/react-ui/src/pages/ModelDeployment/components/ServerLog/index.less new file mode 100644 index 00000000..4b7f5762 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/ServerLog/index.less @@ -0,0 +1,28 @@ +.server-log { + height: 100%; + &__data { + height: calc(100% - 42px); + margin-top: 10px; + padding: 10px; + overflow-y: auto; + color: white; + white-space: pre-wrap; + background-color: rgba(0, 0, 0, 0.85); + font-family: 'Roboto Mono', 'Menlo', 'Consolas', 'Monaco', monospace; + display: flex; + flex-direction: column; + align-items: left; + + &--empty { + align-items: center; + } + + &__more { + padding: 20px 0; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.5); + } + } +} diff --git a/react-ui/src/pages/ModelDeployment/components/ServerLog/index.tsx b/react-ui/src/pages/ModelDeployment/components/ServerLog/index.tsx new file mode 100644 index 00000000..40665ef0 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/ServerLog/index.tsx @@ -0,0 +1,152 @@ +import { ServiceVersionData } from '@/pages/ModelDeployment/types'; +import { getServiceVersionLogReq } from '@/services/modelDeployment'; +import { to } from '@/utils/promise'; +import { DoubleRightOutlined } from '@ant-design/icons'; +import { Button, DatePicker, type TimeRangePickerProps } from 'antd'; +import classNames from 'classnames'; +import dayjs from 'dayjs'; +import { useEffect, useState } from 'react'; +import styles from './index.less'; +const { RangePicker } = DatePicker; + +// 滚动到底部 +// const scrollToBottom = (smooth: boolean = true) => { +// const element = document.getElementById('server-log'); +// if (element) { +// const optons: ScrollToOptions = { +// top: element.scrollHeight, +// behavior: smooth ? 'smooth' : 'instant', +// }; +// element.scrollTo(optons); +// } +// }; + +type LogData = { + log_content: string; + end_time: string; + start_time: string; +}; + +type ServerLogProps = { + info?: ServiceVersionData; +}; + +function ServerLog({ info }: ServerLogProps) { + const [dateRange, setDateRange] = useState>([ + dayjs().add(-1, 'h'), + dayjs(), + ]); + const [logTime, setLogTime] = useState<[string, string]>([ + `${dateRange[0]!.valueOf() * Math.pow(10, 6)}`, + `${dateRange[1]!.valueOf() * Math.pow(10, 6)}`, + ]); + const [logData, setLogData] = useState([]); + const [hasMore, setHasMore] = useState(false); + + const rangePresets: TimeRangePickerProps['presets'] = [ + { label: '最近 1 小时', value: [dayjs().add(-1, 'h'), dayjs()] }, + { label: '最近 2 小时', value: [dayjs().add(-2, 'h'), dayjs()] }, + { label: '最近 3 小时', value: [dayjs().add(-3, 'h'), dayjs()] }, + { label: '最近 1 天', value: [dayjs().add(-1, 'd'), dayjs()] }, + { label: '最近 2 天', value: [dayjs().add(-2, 'd'), dayjs()] }, + { label: '最近 7 天', value: [dayjs().add(-7, 'd'), dayjs()] }, + { label: '最近 14 天', value: [dayjs().add(-14, 'd'), dayjs()] }, + { label: '最近 30 天', value: [dayjs().add(-30, 'd'), dayjs()] }, + ]; + + useEffect(() => { + // 获取服务日志 + const getModelDeploymentLog = async () => { + if (info && logTime && logTime.length === 2) { + const params = { + start_time: logTime[0], + end_time: logTime[1], + id: info.id, + }; + const [res] = await to(getServiceVersionLogReq(params)); + if (res && res.data) { + setLogData((prev) => [...prev, res.data]); + setHasMore(!!res.data.log_content); + // setTimeout(() => { + // scrollToBottom(); + // }, 100); + } + } + }; + getModelDeploymentLog(); + }, [info, logTime]); + + // 搜索 + const handleSearch = () => { + setLogData([]); + setHasMore(false); + setLogTime([ + `${dateRange[0]!.valueOf() * Math.pow(10, 6)}`, + `${dateRange[1]!.valueOf() * Math.pow(10, 6)}`, + ]); + }; + + // 加载更多日志 + const loadMoreLog = () => { + const lastLog = logData[logData.length - 1]; + setLogTime([lastLog.start_time, lastLog.end_time]); + }; + + // 禁止选择今天之后和之前31天的日期 + const disabledDate: TimeRangePickerProps['disabledDate'] = (currentDate) => { + return ( + Date.now() - currentDate.valueOf() < 0 || + Date.now() - currentDate.valueOf() > 31 * 24 * 60 * 60 * 1000 + ); + }; + + // 处理日期变化 + const handleRangeChange: TimeRangePickerProps['onChange'] = (dates) => { + if (dates) { + setDateRange(dates); + } + }; + + const logContent = logData.map((v) => v.log_content).join(''); + + return ( +
+
+ + +
+ {logContent ? ( +
+
{logContent}
+ {hasMore && ( + + )} +
+ ) : ( +
+ 暂无日志 +
+ )} +
+ ); +} + +export default ServerLog; diff --git a/react-ui/src/pages/ModelDeployment/components/UserGuide/index.less b/react-ui/src/pages/ModelDeployment/components/UserGuide/index.less new file mode 100644 index 00000000..2ab1f679 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/UserGuide/index.less @@ -0,0 +1,8 @@ +.user-guide { + height: 100%; + padding: 10px; + overflow-y: auto; + color: white; + white-space: pre-wrap; + background-color: rgba(0, 0, 0, 0.85); +} diff --git a/react-ui/src/pages/ModelDeployment/components/UserGuide/index.tsx b/react-ui/src/pages/ModelDeployment/components/UserGuide/index.tsx new file mode 100644 index 00000000..49eca067 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/UserGuide/index.tsx @@ -0,0 +1,30 @@ +import { ServiceVersionData } from '@/pages/ModelDeployment/types'; +import { getServiceVersionDocsReq } from '@/services/modelDeployment'; +import { to } from '@/utils/promise'; +import { useEffect, useState } from 'react'; +import styles from './index.less'; + +type UserGuideProps = { + info?: ServiceVersionData; +}; + +function UserGuide({ info }: UserGuideProps) { + const [docs, setDocs] = useState(''); + + useEffect(() => { + // 获取服务文档 + const getModelDeploymentDocs = async () => { + if (info) { + const [res] = await to(getServiceVersionDocsReq(info.id)); + if (res && res.data && res.data.docs) { + setDocs(JSON.stringify(res.data.docs, null, 2)); + } + } + }; + getModelDeploymentDocs(); + }, [info]); + + return
{docs}
; +} + +export default UserGuide; diff --git a/react-ui/src/pages/ModelDeployment/components/VersionBasicInfo/index.less b/react-ui/src/pages/ModelDeployment/components/VersionBasicInfo/index.less new file mode 100644 index 00000000..0995b6c7 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/VersionBasicInfo/index.less @@ -0,0 +1,6 @@ +.basic-info { + a:hover { + text-decoration: underline @underline-color; + text-underline-offset: 3px; + } +} diff --git a/react-ui/src/pages/ModelDeployment/components/VersionBasicInfo/index.tsx b/react-ui/src/pages/ModelDeployment/components/VersionBasicInfo/index.tsx new file mode 100644 index 00000000..47d03d61 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/VersionBasicInfo/index.tsx @@ -0,0 +1,122 @@ +import BasicInfo, { type BasicInfoData } from '@/components/BasicInfo'; +import { ServiceRunStatus } from '@/enums'; +import { useSystemResource } from '@/hooks/useComputingResource'; +import { ServiceVersionData } from '@/pages/ModelDeployment/types'; +import { formatDate } from '@/utils/date'; +import { formatMirror, formatModel } from '@/utils/format'; +import { Flex } from 'antd'; +import ModelDeployStatusCell from '../ModelDeployStatusCell'; + +type BasicInfoProps = { + info?: ServiceVersionData; +}; + +// 格式化状态 +const formatStatus = (status?: ServiceRunStatus) => { + if (!status) { + return undefined; + } + + return ( + + {ModelDeployStatusCell(status)} + + ); +}; + +// 格式化环境变量 +const formatEnvText = (env?: Record) => { + if (!env || Object.keys(env).length === 0) { + return undefined; + } + + return Object.entries(env).map(([key, value]) => ({ + value: `${key}: ${value}`, + })); +}; + +function VersionBasicInfo({ info }: BasicInfoProps) { + const getResourceDescription = useSystemResource(); + + const datas: BasicInfoData[] = [ + { + label: '服务名称', + value: info?.service_name, + }, + { + label: '版本名称', + value: info?.version, + }, + // { + // label: '代码配置', + // value: info?.code_config, + // format: formatCodeConfig, + // }, + { + label: '镜像', + value: info?.image, + format: formatMirror, + }, + { + label: '状态', + value: info?.run_state, + format: formatStatus, + }, + { + label: '模型', + value: info?.model, + format: formatModel, + }, + { + label: '资源规格', + value: info?.computing_resource_id, + format: getResourceDescription, + }, + { + label: '挂载路径', + value: info?.mount_path, + }, + { + label: 'API URL', + value: info?.url, + }, + { + label: '文档地址', + value: info?.doc_path, + }, + { + label: '副本数量', + value: info?.replicas, + }, + { + label: '创建时间', + value: info?.create_time, + format: formatDate, + }, + { + label: '更新时间', + value: info?.update_time, + format: formatDate, + }, + { + label: '环境变量', + value: info?.env_variables, + format: formatEnvText, + }, + { + label: '描述', + value: info?.description, + }, + ]; + + return ( + + ); +} + +export default VersionBasicInfo; diff --git a/react-ui/src/pages/ModelDeployment/components/VersionCompareModal/index.less b/react-ui/src/pages/ModelDeployment/components/VersionCompareModal/index.less new file mode 100644 index 00000000..f1935eb2 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/VersionCompareModal/index.less @@ -0,0 +1,121 @@ +@purple-color: #6516ff; + +.title(@color, @background) { + width: 100%; + margin-bottom: 20px; + padding: 0 15px; + color: @color; + font-weight: 500; + font-size: @font-size; + line-height: 42px; + text-align: center; + background: @background; + border-radius: 4px 4px 0 0; + .singleLine(); +} + +.text() { + margin-bottom: 20px !important; + color: @text-color-secondary; + font-size: 13px; + line-height: 22px; + word-break: break-all; + .singleLine(); +} + +.version-container(@background) { + flex: 1; + min-width: 0; + background: @background; + border-radius: 4px; +} + +.version-compare { + :global { + .ant-modal-content { + padding: 40px 40px 25px !important; + } + .ant-modal-header { + margin-bottom: 20px !important; + } + .kf-modal-title { + color: @text-color; + font-weight: 500; + font-size: 20px; + } + } + + &__container { + display: flex; + flex-wrap: nowrap; + gap: 0 5px; + align-items: stretch; + height: 100%; + } + + &__fields { + flex: none; + width: 117px; + padding: 0 15px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid .addAlpha(@primary-color, 0.2) []; + border-radius: 4px; + box-shadow: 0px 3px 6px .addAlpha(@primary-color, 0.1) [] inset; + + &__title { + margin-bottom: 20px; + color: @text-color; + font-size: @font-size; + line-height: 42px; + } + &__text { + .text(); + + &--different { + color: @error-color; + } + } + } + + &__left { + .version-container(.addAlpha(@primary-color, 0.04) []); + + &__title { + .title(@primary-color, linear-gradient( + 159.9deg,rgba(138, 177, 255, 0.5) 0%, + rgba(22, 100, 255, 0.5) 100% + )); + } + + &__text { + padding: 0 15px; + text-align: center; + .text(); + + &--different { + color: @primary-color; + } + } + } + + &__right { + .version-container(rgba(100, 30, 237, 0.04)); + &__title { + .title(@purple-color, linear-gradient( + 159.9deg, + rgba(193, 138, 255, 0.5) 0%, + rgba(146, 22, 255, 0.5) 100% + )); + } + + &__text { + padding: 0 15px; + text-align: center; + .text(); + + &--different { + color: @purple-color; + } + } + } +} diff --git a/react-ui/src/pages/ModelDeployment/components/VersionCompareModal/index.tsx b/react-ui/src/pages/ModelDeployment/components/VersionCompareModal/index.tsx new file mode 100644 index 00000000..c31e4700 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/components/VersionCompareModal/index.tsx @@ -0,0 +1,201 @@ +import KFModal from '@/components/KFModal'; +import { ServiceRunStatus } from '@/enums'; +import { useSystemResource } from '@/hooks/useComputingResource'; +import { type ServiceVersionData } from '@/pages/ModelDeployment/types'; +import { getServiceVersionCompareReq } from '@/services/modelDeployment'; +import { isEmpty } from '@/utils'; +import { to } from '@/utils/promise'; +import { Typography, type ModalProps } from 'antd'; +import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; +import { statusInfo } from '../ModelDeployStatusCell'; +import styles from './index.less'; + +type CompareData = { + differences: Record; + version1: ServiceVersionData; + version2: ServiceVersionData; +}; + +type ServiceVersionDataKey = keyof ServiceVersionData; + +type FiledType = { + key: ServiceVersionDataKey; + text: string; + format?: (data: any) => any; +}; + +interface VersionCompareModalProps extends Omit { + version1: string; + version2: string; +} + +// 格式化环境变量 +const formatEnvText = (env: Record) => { + if (!env || Object.keys(env).length === 0) { + return '--'; + } + return Object.entries(env) + .map(([key, value]) => `${key} = ${value}`) + .join(','); +}; + +function VersionCompareModal({ version1, version2, ...rest }: VersionCompareModalProps) { + const [compareData, setCompareData] = useState(undefined); + const getResourceDescription = useSystemResource(); + + const fields: FiledType[] = useMemo( + () => [ + { + key: 'service_name', + text: '服务名称', + }, + { + key: 'run_state', + text: '状态', + format: (data: any) => { + return data ? statusInfo[data as ServiceRunStatus].text : '--'; + }, + }, + { + key: 'image', + text: '镜像', + format: (data: any) => { + return data?.path; + }, + }, + { + key: 'code_config', + text: '代码配置', + format: (data: any) => { + return data?.show_value; + }, + }, + { + key: 'model', + text: '模型', + format: (data: any) => { + return data?.show_value; + }, + }, + { + key: 'computing_resource_id', + text: '资源规格', + format: getResourceDescription, + }, + { + key: 'replicas', + text: '副本数', + }, + { + key: 'mount_path', + text: '挂载路径', + }, + { + key: 'url', + text: '服务URL', + }, + { + key: 'env_variables', + text: '环境变量', + format: formatEnvText, + }, + { + key: 'description', + text: '描述', + }, + ], + [getResourceDescription], + ); + + useEffect(() => { + // 获取对比数据 + const getServiceVersionCompare = async () => { + const params = { + id1: version1, + id2: version2, + }; + const [res] = await to(getServiceVersionCompareReq(params)); + if (res && res.data) { + setCompareData(res.data); + } + }; + + getServiceVersionCompare(); + }, [version1, version2]); + + const { + version1: v1 = {} as ServiceVersionData, + version2: v2 = {} as ServiceVersionData, + differences = {}, + } = compareData || {}; + + const isDifferent = (key: ServiceVersionDataKey) => { + const keys = Object.keys(differences); + return keys.includes(key); + }; + + return ( + +
+
+
基础版本号
+ {fields.map(({ key, text }) => ( +
+ {text} +
+ ))} +
+
+
{v1.version}
+ {fields.map(({ key, format }) => { + const text = format ? format(v1[key]) : v1[key]; + return ( +
+ + {isEmpty(text) ? '--' : text} + +
+ ); + })} +
+
+
{v2.version}
+ {fields.map(({ key, format }) => { + const text = format ? format(v2[key]) : v2[key]; + return ( +
+ + {isEmpty(text) ? '--' : text} + +
+ ); + })} +
+
+
+ ); +} + +export default VersionCompareModal; diff --git a/react-ui/src/pages/ModelDeployment/types.ts b/react-ui/src/pages/ModelDeployment/types.ts new file mode 100644 index 00000000..d333d9f2 --- /dev/null +++ b/react-ui/src/pages/ModelDeployment/types.ts @@ -0,0 +1,67 @@ +import { ServiceRunStatus } from '@/enums'; + +// 服务列表数据类型 +export type ServiceData = { + id: number; // 服务id + service_name: string; // 服务名称 + service_type: string; // 服务类型 + service_type_name: string; // 服务类型中文 + description: string; // 描述 + version_count: number; // 版本数量 + create_by: string; + create_time: string; + update_by: string; + update_time: string; +}; + +// 服务版本数据类型 +export type ServiceVersionData = { + id: number; // 版本id + service_id: number; // 服务id + service_name: string; // 服务名称 + description: string; // 版本描述 + version: string; // 版本 + run_state: ServiceRunStatus; // 运行状态 + image: string; // 镜像 + replicas: number; // 副本数 + computing_resource_id: number; // 资源 + mount_path: string; // 挂载路径 + model: { + // 模型 + id: number; + name: string; + version: string; + path: string; + identifier: string; + owner: string; + showValue: string; + }; + code_config: { + // 代码配置 + code_path: string; + branch: string; + show_value: string; + }; + env_variables: Record; // 环境变量 + url: string; // API URL + deployment_name: string; + update_by: string; + update_time: string; + create_time: string; + created_by: string; + doc_path?: string; // 文档地址 + page_path?: string; // 预测地址 +}; + +// 操作类型 +export enum ServiceOperationType { + Create = 'Create', // 创建 + Update = 'Update', // 更新 + Restart = 'Restart', // 重启 +} + +// 操作类型 +export enum CreateServiceVersionFrom { + CreateService = 'CreateService', // 来自创建服务 + ServiceInfo = 'ServiceInfo', // 来自服务详情 +} diff --git a/react-ui/src/pages/Monitor/Job/detail.tsx b/react-ui/src/pages/Monitor/Job/detail.tsx new file mode 100644 index 00000000..f664b76a --- /dev/null +++ b/react-ui/src/pages/Monitor/Job/detail.tsx @@ -0,0 +1,131 @@ +import { DictValueEnumObj } from '@/components/DictTag'; +import KFModal from '@/components/KFModal'; +import { getValueEnumLabel } from '@/utils/options'; +import { FormattedMessage, useIntl } from '@umijs/max'; +import { Button, Descriptions } from 'antd'; +import React, { useEffect } from 'react'; +/* * + * + * @author whiteshader@163.com + * @datetime 2023/02/07 + * + * */ + +export type OperlogFormValueType = Record & Partial; + +export type OperlogFormProps = { + onCancel: (flag?: boolean, formVals?: OperlogFormValueType) => void; + open: boolean; + values: Partial; + statusOptions: DictValueEnumObj; +}; + +const OperlogForm: React.FC = (props) => { + const { values, statusOptions } = props; + + useEffect(() => {}, [props]); + + const intl = useIntl(); + + const misfirePolicy: any = { + '0': '默认策略', + '1': '立即执行', + '2': '执行一次', + '3': '放弃执行', + }; + + const handleCancel = () => { + props.onCancel(); + }; + + return ( + + 关闭 + , + ]} + > + + } + > + {values.jobId} + + } + > + {values.jobName} + + } + > + {values.jobGroup} + + } + > + {values.concurrent === '1' ? '禁止' : '允许'} + + + } + > + {misfirePolicy[values.misfirePolicy ? values.misfirePolicy : '0']} + + } + > + {values.createTime?.toString()} + + } + > + {getValueEnumLabel(statusOptions, values.status, '未知')} + + + } + > + {values.nextValidTime} + + + } + > + {values.cronExpression} + + + } + > + {values.invokeTarget} + + + + ); +}; + +export default OperlogForm; diff --git a/react-ui/src/pages/Monitor/Job/edit.tsx b/react-ui/src/pages/Monitor/Job/edit.tsx new file mode 100644 index 00000000..bcc48606 --- /dev/null +++ b/react-ui/src/pages/Monitor/Job/edit.tsx @@ -0,0 +1,251 @@ +import { DictOptionType, DictValueEnumObj } from '@/components/DictTag'; +import KFModal from '@/components/KFModal'; +import { + ProForm, + ProFormCaptcha, + ProFormDigit, + ProFormRadio, + ProFormSelect, + ProFormText, + ProFormTextArea, +} from '@ant-design/pro-components'; +import { FormattedMessage, useIntl } from '@umijs/max'; +import { Form } from 'antd'; +import React, { useEffect } from 'react'; +/** + * 定时任务调度 Edit Form + * + * @author whiteshader + * @date 2023-02-07 + */ + +export type JobFormData = Record & Partial; + +export type JobFormProps = { + onCancel: (flag?: boolean, formVals?: JobFormData) => void; + onSubmit: (values: JobFormData) => Promise; + open: boolean; + values: Partial; + jobGroupOptions: DictOptionType[]; + statusOptions: DictValueEnumObj; +}; + +const JobForm: React.FC = (props) => { + const [form] = Form.useForm(); + const { jobGroupOptions, statusOptions } = props; + const formLayout = { + labelCol: { span: 5 }, + wrapperCol: { span: 19 }, + }; + + useEffect(() => { + form.resetFields(); + form.setFieldsValue({ + jobId: props.values.jobId, + jobName: props.values.jobName, + jobGroup: props.values.jobGroup, + invokeTarget: props.values.invokeTarget, + cronExpression: props.values.cronExpression, + misfirePolicy: props.values.misfirePolicy, + concurrent: props.values.concurrent, + status: props.values.status ?? Object.keys(statusOptions)[0], + createBy: props.values.createBy, + createTime: props.values.createTime, + updateBy: props.values.updateBy, + updateTime: props.values.updateTime, + remark: props.values.remark, + }); + }, [form, props, statusOptions]); + + const intl = useIntl(); + const handleOk = () => { + form.submit(); + }; + const handleCancel = () => { + props.onCancel(); + form.resetFields(); + }; + const handleFinish = async (values: Record) => { + props.onSubmit(values as JobFormData); + }; + + return ( + + + + + ); +}; + +export default JobForm; diff --git a/react-ui/src/pages/Monitor/Job/index.tsx b/react-ui/src/pages/Monitor/Job/index.tsx new file mode 100644 index 00000000..6bf166d9 --- /dev/null +++ b/react-ui/src/pages/Monitor/Job/index.tsx @@ -0,0 +1,475 @@ +import DictTag from '@/components/DictTag'; +import { + addJob, + exportJob, + getJobList, + removeJob, + runJob, + updateJob, +} from '@/services/monitor/job'; +import { getDictSelectOption, getDictValueEnum } from '@/services/system/dict'; +import { + DeleteOutlined, + DownOutlined, + EditOutlined, + ExclamationCircleOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { + ActionType, + FooterToolbar, + PageContainer, + ProColumns, + ProTable, +} from '@ant-design/pro-components'; +import { FormattedMessage, history, useAccess, useIntl } from '@umijs/max'; +import { Button, Dropdown, FormInstance, Modal, Space, message } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; +import DetailForm from './detail'; +import UpdateForm from './edit'; + +/** + * 定时任务调度 List Page + * + * @author whiteshader + * @date 2023-02-07 + */ + +/** + * 添加节点 + * + * @param fields + */ +const handleAdd = async (fields: API.Monitor.Job) => { + const hide = message.loading('正在添加'); + try { + const resp = await addJob({ ...fields }); + hide(); + if (resp.code === 200) { + message.success('添加成功'); + } else { + message.error(resp.msg); + } + return true; + } catch (error) { + hide(); + message.error('添加失败请重试!'); + return false; + } +}; + +/** + * 更新节点 + * + * @param fields + */ +const handleUpdate = async (fields: API.Monitor.Job) => { + const hide = message.loading('正在更新'); + try { + const resp = await updateJob(fields); + hide(); + if (resp.code === 200) { + message.success('更新成功'); + } else { + message.error(resp.msg); + } + return true; + } catch (error) { + hide(); + message.error('配置失败请重试!'); + return false; + } +}; + +/** + * 删除节点 + * + * @param selectedRows + */ +const handleRemove = async (selectedRows: API.Monitor.Job[]) => { + const hide = message.loading('正在删除'); + if (!selectedRows) return true; + try { + const resp = await removeJob(selectedRows.map((row) => row.jobId).join(',')); + hide(); + if (resp.code === 200) { + message.success('删除成功,即将刷新'); + } else { + message.error(resp.msg); + } + return true; + } catch (error) { + hide(); + message.error('删除失败,请重试'); + return false; + } +}; + +const handleRemoveOne = async (selectedRow: API.Monitor.Job) => { + const hide = message.loading('正在删除'); + if (!selectedRow) return true; + try { + const params = [selectedRow.jobId]; + const resp = await removeJob(params.join(',')); + hide(); + if (resp.code === 200) { + message.success('删除成功,即将刷新'); + } else { + message.error(resp.msg); + } + return true; + } catch (error) { + hide(); + message.error('删除失败,请重试'); + return false; + } +}; + +/** + * 导出数据 + * + */ +const handleExport = async () => { + const hide = message.loading('正在导出'); + try { + await exportJob(); + hide(); + message.success('导出成功'); + return true; + } catch (error) { + hide(); + message.error('导出失败,请重试'); + return false; + } +}; + +const JobTableList: React.FC = () => { + const formTableRef = useRef(); + + const [modalVisible, setModalVisible] = useState(false); + const [detailModalVisible, setDetailModalVisible] = useState(false); + + const actionRef = useRef(); + const [currentRow, setCurrentRow] = useState(); + const [selectedRows, setSelectedRows] = useState([]); + + const [jobGroupOptions, setJobGroupOptions] = useState([]); + const [statusOptions, setStatusOptions] = useState([]); + + const access = useAccess(); + + /** 国际化配置 */ + const intl = useIntl(); + + useEffect(() => { + getDictSelectOption('sys_job_group').then((data) => { + setJobGroupOptions(data); + }); + getDictValueEnum('sys_normal_disable').then((data) => { + setStatusOptions(data); + }); + }, []); + + const columns: ProColumns[] = [ + { + title: , + dataIndex: 'jobId', + valueType: 'text', + hideInSearch: true, + }, + { + title: , + dataIndex: 'jobName', + valueType: 'text', + render: (dom, record) => { + return ( + { + setDetailModalVisible(true); + setCurrentRow(record); + }} + > + {dom} + + ); + }, + }, + { + title: , + dataIndex: 'jobGroup', + valueType: 'text', + valueEnum: jobGroupOptions, + render: (_, record) => { + return ; + }, + }, + { + title: , + dataIndex: 'invokeTarget', + valueType: 'textarea', + }, + { + title: , + dataIndex: 'cronExpression', + valueType: 'text', + }, + { + title: , + dataIndex: 'status', + valueType: 'select', + valueEnum: statusOptions, + render: (_, record) => { + return ; + }, + }, + { + title: , + dataIndex: 'option', + width: '220px', + valueType: 'option', + render: (_, record) => [ + , + , + { + if (key === 'runOnce') { + Modal.confirm({ + title: '警告', + content: '确认要立即执行一次?', + okText: '确认', + cancelText: '取消', + onOk: async () => { + const success = await runJob(record.jobId, record.jobGroup); + if (success) { + message.success('执行成功'); + } + }, + }); + } else if (key === 'detail') { + setDetailModalVisible(true); + setCurrentRow(record); + } else if (key === 'log') { + history.push(`/monitor/job-log/index/${record.jobId}`); + } + }, + }} + > + e.preventDefault()}> + + + 更多 + + + , + ], + }, + ]; + + return ( + +
+ + headerTitle={intl.formatMessage({ + id: 'pages.searchTable.title', + defaultMessage: '信息', + })} + actionRef={actionRef} + formRef={formTableRef} + rowKey="jobId" + key="jobList" + search={{ + labelWidth: 120, + }} + scroll={{ y: 'calc(100% - 55px)' }} + tableAlertRender={false} + tableAlertOptionRender={false} + toolBarRender={() => [ + , + , + , + ]} + request={(params) => + getJobList({ ...params } as API.Monitor.JobListParams).then((res) => { + const result = { + data: res.rows, + total: res.total, + success: true, + }; + return result; + }) + } + columns={columns} + rowSelection={{ + onChange: (_, selectedRows) => { + setSelectedRows(selectedRows); + }, + }} + /> +
+ {selectedRows?.length > 0 && ( + + + {selectedRows.length} + + + } + > + + + )} + { + let success = false; + if (values.jobId) { + success = await handleUpdate({ ...values } as API.Monitor.Job); + } else { + success = await handleAdd({ ...values } as API.Monitor.Job); + } + if (success) { + setModalVisible(false); + setCurrentRow(undefined); + if (actionRef.current) { + actionRef.current.reload(); + } + } + }} + onCancel={() => { + setModalVisible(false); + setCurrentRow(undefined); + }} + open={modalVisible} + values={currentRow || {}} + jobGroupOptions={jobGroupOptions || {}} + statusOptions={statusOptions} + /> + { + setDetailModalVisible(false); + setCurrentRow(undefined); + }} + open={detailModalVisible} + values={currentRow || {}} + statusOptions={statusOptions} + /> +
+ ); +}; + +export default JobTableList; diff --git a/react-ui/src/pages/Monitor/JobLog/detail.tsx b/react-ui/src/pages/Monitor/JobLog/detail.tsx new file mode 100644 index 00000000..10cac4e4 --- /dev/null +++ b/react-ui/src/pages/Monitor/JobLog/detail.tsx @@ -0,0 +1,96 @@ +import { DictValueEnumObj } from '@/components/DictTag'; +import KFModal from '@/components/KFModal'; +import { getValueEnumLabel } from '@/utils/options'; +import { FormattedMessage, useIntl } from '@umijs/max'; +import { Descriptions } from 'antd'; +import React, { useEffect } from 'react'; + +export type JobLogFormValueType = Record & Partial; + +export type JobLogFormProps = { + onCancel: (flag?: boolean, formVals?: JobLogFormValueType) => void; + open: boolean; + values: Partial; + statusOptions: DictValueEnumObj; + jobGroupOptions: DictValueEnumObj; +}; + +const JobLogDetailForm: React.FC = (props) => { + const { values, statusOptions, jobGroupOptions } = props; + + useEffect(() => {}, []); + + const intl = useIntl(); + const handleOk = () => {}; + const handleCancel = () => { + props.onCancel(); + }; + + return ( + + + } + > + {values.jobLogId} + + } + > + {values.createTime?.toString()} + + } + > + {values.jobName} + + } + > + {getValueEnumLabel(jobGroupOptions, values.jobGroup, '无')} + + } + > + {values.invokeTarget} + + } + > + {values.jobMessage} + + } + > + {values.exceptionInfo} + + } + > + {getValueEnumLabel(statusOptions, values.status, '未知')} + + + + ); +}; + +export default JobLogDetailForm; diff --git a/react-ui/src/pages/Monitor/JobLog/index.tsx b/react-ui/src/pages/Monitor/JobLog/index.tsx new file mode 100644 index 00000000..9af85b9c --- /dev/null +++ b/react-ui/src/pages/Monitor/JobLog/index.tsx @@ -0,0 +1,350 @@ +import DictTag from '@/components/DictTag'; +import { getJob } from '@/services/monitor/job'; +import { exportJobLog, getJobLogList, removeJobLog } from '@/services/monitor/jobLog'; +import { getDictValueEnum } from '@/services/system/dict'; +import { DeleteOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { + ActionType, + FooterToolbar, + PageContainer, + ProColumns, + ProTable, +} from '@ant-design/pro-components'; +import { FormattedMessage, history, useAccess, useIntl, useParams } from '@umijs/max'; +import type { FormInstance } from 'antd'; +import { Button, Modal, message } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; +import DetailForm from './detail'; + +/** + * 定时任务调度日志 List Page + * + * @author whiteshader + * @date 2023-02-07 + */ + +/** + * 删除节点 + * + * @param selectedRows + */ +const handleRemove = async (selectedRows: API.Monitor.JobLog[]) => { + const hide = message.loading('正在删除'); + if (!selectedRows) return true; + try { + const resp = await removeJobLog(selectedRows.map((row) => row.jobLogId).join(',')); + hide(); + if (resp.code === 200) { + message.success('删除成功,即将刷新'); + } else { + message.error(resp.msg); + } + return true; + } catch (error) { + hide(); + message.error('删除失败,请重试'); + return false; + } +}; + +const handleRemoveOne = async (selectedRow: API.Monitor.JobLog) => { + const hide = message.loading('正在删除'); + if (!selectedRow) return true; + try { + const params = [selectedRow.jobLogId]; + const resp = await removeJobLog(params.join(',')); + hide(); + if (resp.code === 200) { + message.success('删除成功,即将刷新'); + } else { + message.error(resp.msg); + } + return true; + } catch (error) { + hide(); + message.error('删除失败,请重试'); + return false; + } +}; + +/** + * 清空日志数据 + * + */ +const handleExport = async () => { + const hide = message.loading('正在导出'); + try { + await exportJobLog(); + hide(); + message.success('导出成功'); + return true; + } catch (error) { + hide(); + message.error('导出失败,请重试'); + return false; + } +}; + +const JobLogTableList: React.FC = () => { + const formTableRef = useRef(); + + const [modalOpen, setModalOpen] = useState(false); + + const actionRef = useRef(); + const [currentRow, setCurrentRow] = useState(); + const [selectedRows, setSelectedRows] = useState([]); + + const [jobGroupOptions, setJobGroupOptions] = useState([]); + const [statusOptions, setStatusOptions] = useState([]); + + const [queryParams, setQueryParams] = useState([]); + + const access = useAccess(); + + /** 国际化配置 */ + const intl = useIntl(); + + const params = useParams(); + if (params.id === undefined) { + history.push('/monitor/job'); + } + const jobId = params.id || 0; + useEffect(() => { + if (jobId !== undefined && jobId !== 0) { + getJob(Number(jobId)).then((response) => { + setQueryParams({ + jobName: response.data.jobName, + jobGroup: response.data.jobGroup, + }); + }); + } + getDictValueEnum('sys_job_status').then((data) => { + setStatusOptions(data); + }); + getDictValueEnum('sys_job_group').then((data) => { + setJobGroupOptions(data); + }); + }, [jobId]); + + const columns: ProColumns[] = [ + { + title: , + dataIndex: 'jobLogId', + valueType: 'text', + hideInSearch: true, + }, + { + title: , + dataIndex: 'jobName', + valueType: 'text', + }, + { + title: , + dataIndex: 'jobGroup', + valueType: 'text', + }, + { + title: ( + + ), + dataIndex: 'invokeTarget', + valueType: 'textarea', + }, + { + title: , + dataIndex: 'jobMessage', + valueType: 'textarea', + }, + { + title: , + dataIndex: 'status', + valueType: 'select', + valueEnum: statusOptions, + render: (_, record) => { + return ; + }, + }, + { + title: , + dataIndex: 'createTime', + valueType: 'text', + }, + { + title: , + dataIndex: 'option', + width: '120px', + valueType: 'option', + render: (_, record) => [ + , + , + ], + }, + ]; + + return ( + +
+ + headerTitle={intl.formatMessage({ + id: 'pages.searchTable.title', + defaultMessage: '信息', + })} + actionRef={actionRef} + formRef={formTableRef} + rowKey="jobLogId" + key="job-logList" + search={{ + labelWidth: 120, + }} + scroll={{ y: 'calc(100% - 55px)' }} + tableAlertRender={false} + tableAlertOptionRender={false} + toolBarRender={() => [ + , + , + , + ]} + params={queryParams} + request={(params) => + getJobLogList({ ...params } as API.Monitor.JobLogListParams).then((res) => { + const result = { + data: res.rows, + total: res.total, + success: true, + }; + return result; + }) + } + columns={columns} + rowSelection={{ + onChange: (_, selectedRows) => { + setSelectedRows(selectedRows); + }, + }} + /> +
+ {selectedRows?.length > 0 && ( + + + {selectedRows.length} + + + } + > + + + )} + { + setModalOpen(false); + setCurrentRow(undefined); + }} + open={modalOpen} + values={currentRow || {}} + statusOptions={statusOptions} + jobGroupOptions={jobGroupOptions} + /> +
+ ); +}; + +export default JobLogTableList; diff --git a/react-ui/src/pages/Monitor/Online/index.tsx b/react-ui/src/pages/Monitor/Online/index.tsx new file mode 100644 index 00000000..105d568c --- /dev/null +++ b/react-ui/src/pages/Monitor/Online/index.tsx @@ -0,0 +1,160 @@ +import { forceLogout, getOnlineUserList } from '@/services/monitor/online'; +import { DeleteOutlined } from '@ant-design/icons'; +import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components'; +import { FormattedMessage, useAccess, useIntl } from '@umijs/max'; +import type { FormInstance } from 'antd'; +import { Button, Modal, message } from 'antd'; +import React, { useEffect, useRef } from 'react'; + +/* * + * + * @author whiteshader@163.com + * @datetime 2023/02/07 + * + * */ + +const handleForceLogout = async (selectedRow: API.Monitor.OnlineUserType) => { + const hide = message.loading('正在强制下线'); + try { + await forceLogout(selectedRow.tokenId); + hide(); + message.success('强制下线成功,即将刷新'); + return true; + } catch (error) { + hide(); + message.error('强制下线失败,请重试'); + return false; + } +}; + +const OnlineUserTableList: React.FC = () => { + const formTableRef = useRef(); + const actionRef = useRef(); + const access = useAccess(); + const intl = useIntl(); + + useEffect(() => {}, []); + + const columns: ProColumns[] = [ + { + title: , + dataIndex: 'tokenId', + valueType: 'text', + hideInSearch: true, + }, + { + title: , + dataIndex: 'userName', + valueType: 'text', + }, + { + title: , + dataIndex: 'deptName', + valueType: 'text', + hideInSearch: true, + }, + { + title: , + dataIndex: 'ipaddr', + valueType: 'text', + }, + { + title: , + dataIndex: 'loginLocation', + valueType: 'text', + hideInSearch: true, + }, + { + title: , + dataIndex: 'browser', + valueType: 'text', + hideInSearch: true, + }, + { + title: , + dataIndex: 'os', + valueType: 'text', + hideInSearch: true, + }, + { + title: , + dataIndex: 'loginTime', + valueType: 'dateRange', + render: (_, record) => {record.loginTime}, + hideInSearch: true, + search: { + transform: (value) => { + return { + 'params[beginTime]': value[0], + 'params[endTime]': value[1], + }; + }, + }, + }, + { + title: , + dataIndex: 'option', + width: '60px', + valueType: 'option', + render: (_, record) => [ + , + ], + }, + ]; + + return ( +
+ + headerTitle={intl.formatMessage({ + id: 'pages.searchTable.title', + defaultMessage: '信息', + })} + actionRef={actionRef} + formRef={formTableRef} + rowKey="tokenId" + key="logininforList" + search={{ + labelWidth: 120, + }} + request={(params) => + getOnlineUserList({ ...params } as API.Monitor.OnlineUserListParams).then((res) => { + const result = { + data: res.rows, + total: res.total, + success: true, + }; + return result; + }) + } + columns={columns} + /> +
+ ); +}; + +export default OnlineUserTableList; diff --git a/react-ui/src/pages/Pipeline/Info/index.jsx b/react-ui/src/pages/Pipeline/Info/index.jsx new file mode 100644 index 00000000..c0e0c51c --- /dev/null +++ b/react-ui/src/pages/Pipeline/Info/index.jsx @@ -0,0 +1,772 @@ +import KFIcon from '@/components/KFIcon'; +import { useStateRef } from '@/hooks/useStateRef'; +import { useVisible } from '@/hooks/useVisible'; +import { getWorkflowById, saveWorkflow } from '@/services/pipeline/index.js'; +import themes from '@/styles/theme.less'; +import { fittingString, s8 } from '@/utils'; +import { to } from '@/utils/promise'; +import G6 from '@antv/g6'; +import { useNavigate, useParams } from '@umijs/max'; +import { App, Button } from 'antd'; +import { useEffect, useRef, useState } from 'react'; +import GlobalParamsDrawer from '../components/GlobalParamsDrawer'; +import ModelMenu from '../components/ModelMenu'; +import Props from '../components/PipelineNodeDrawer'; +import styles from './index.less'; +import { findAllParentNodes } from './utils'; + +let graph = null; + +const EditPipeline = () => { + const navigate = useNavigate(); + const locationParams = useParams(); //新版本获取路由参数接口 + const graphRef = useRef(); + const paramsDrawerRef = useRef(); + const propsRef = useRef(); + const [paramsDrawerOpen, openParamsDrawer, closeParamsDrawer] = useVisible(false); + const [globalParam, setGlobalParam, globalParamRef] = useStateRef([]); + const [workflowInfo, setWorkflowInfo] = useState(undefined); + const { message } = App.useApp(); + let sourceAnchorIdx, targetAnchorIdx, dropAnchorIdx; + let dragSourceNode; + + useEffect(() => { + initGraph(); + getFirstWorkflow(locationParams.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const changeSize = () => { + if (!graph || graph.get('destroyed')) return; + if (!graphRef.current) return; + graph.changeSize(graphRef.current.clientWidth, graphRef.current.clientHeight); + graph.fitView(); + }; + + window.addEventListener('resize', changeSize); + return () => { + window.removeEventListener('resize', changeSize); + }; + }, []); + + // 拖拽结束,添加新节点 + const onDragEnd = (val) => { + const { x, y } = val; + const point = graph.getPointByClient(x, y); + + let label = val.label; + const data = graph.save(); + const nodeLabels = data.nodes.map((v) => v.label); + if (nodeLabels.includes(label)) { + label += '-' + s8(); + } + + // 元模型 + const model = { + ...val, + x: point.x, + y: point.y, + label, + id: val.component_name + '-' + s8(), + isCluster: false, + formError: true, + }; + // console.log('model', model); + graph.addItem('node', model, false); + }; + + // 节点数据发生变化 + const handleFormChange = (val) => { + if (graph) { + const data = graph.save(); + const index = data.nodes.findIndex((item) => { + return item.id === val.id; + }); + data.nodes[index] = val; + const zoom = graph.getZoom(); + // 在拉取新数据重新渲染页面之前先获取点(0, 0)在画布上的位置 + const lastPoint = graph.getCanvasByPoint(0, 0); + graph.changeData(data); + graph.render(); + graph.zoomTo(zoom); + // 获取重新渲染之后点(0, 0)在画布的位置 + const newPoint = graph.getCanvasByPoint(0, 0); + // 移动画布相对位移; + graph.translate(lastPoint.x - newPoint.x, lastPoint.y - newPoint.y); + } + }; + + // 保存 + const savePipeline = async (isBack) => { + // 验证全局参数 + // 现在改为关闭的时候就验证了 + // const [globalParamRes, globalParamError] = await to(paramsDrawerRef.current.validateFields()); + // if (globalParamError) { + // message.error('全局参数配置有误'); + // openParamsDrawer(); + // return; + // } + // closeParamsDrawer(); + + // 以前没有遮挡【保存】按钮时有用 + // 验证节点必填参数 + // const [propsRes, propsError] = await to(propsRef.current.validateFields()); + // if (propsError) { + // message.error('节点必填项必须配置'); + // return; + // } + // propsRef.current.close(); + + setTimeout(() => { + const data = graph.save(); + // console.log(data); + // 验证节点必填参数 + const errorNode = data.nodes.find((item) => item.formError === true); + if (errorNode) { + message.error(`【${errorNode.label}】节点配置验证失败`); + const graphNode = graph.findById(errorNode.id); + if (graphNode) { + openNodeDrawer(graphNode, true); + } + return; + } + + // 验证节点名称是否有重命名 + const nodeLabels = data.nodes.map((v) => v.label); + for (let i = 0; i < nodeLabels.length; i++) { + const current = nodeLabels[i]; + for (let j = i + 1; j < nodeLabels.length; j++) { + const next = nodeLabels[j]; + if (current === next) { + message.error(`存在重名的【${current}】节点`); + return; + } + } + } + + const params = { + ...locationParams, + name: workflowInfo?.name, + dag: data, + global_param: globalParam, + }; + saveWorkflow(params).then((ret) => { + message.success('保存成功'); + setTimeout(() => { + if (isBack) { + navigate({ pathname: `/pipeline/template` }); + } + }, 500); + }); + }, 500); + }; + + // 渲染数据 + const getGraphData = (data) => { + if (graph && data) { + graph.data(data); + graph.render(); + } else { + setTimeout(() => { + getGraphData(data); + }, 500); + } + }; + + // 处理并行边,暂时没有用 + const processParallelEdgesOnAnchorPoint = ( + edges, + offsetDiff = 15, + multiEdgeType = 'cubic-vertical', + singleEdgeType = undefined, + loopEdgeType = undefined, + ) => { + const len = edges.length; + const cod = offsetDiff * 2; + const loopPosition = [ + 'top', + 'top-right', + 'right', + 'bottom-right', + 'bottom', + 'bottom-left', + 'left', + 'top-left', + ]; + const edgeMap = {}; + const tags = []; + const reverses = {}; + for (let i = 0; i < len; i++) { + const edge = edges[i]; + const { source, target, sourceAnchor, targetAnchor } = edge; + const sourceTarget = `${source}|${sourceAnchor}-${target}|${targetAnchor}`; + + if (tags[i]) continue; + if (!edgeMap[sourceTarget]) { + edgeMap[sourceTarget] = []; + } + tags[i] = true; + edgeMap[sourceTarget].push(edge); + for (let j = 0; j < len; j++) { + if (i === j) continue; + const sedge = edges[j]; + const { + source: src, + target: dst, + sourceAnchor: srcAnchor, + targetAnchor: dstAnchor, + } = sedge; + + // 两个节点之间共同的边 + // 第一条的source = 第二条的target + // 第一条的target = 第二条的source + if (!tags[j]) { + if ( + source === dst && + sourceAnchor === dstAnchor && + target === src && + targetAnchor === srcAnchor + ) { + edgeMap[sourceTarget].push(sedge); + tags[j] = true; + reverses[ + `${src}|${srcAnchor}|${dst}|${dstAnchor}|${edgeMap[sourceTarget].length - 1}` + ] = true; + } else if ( + source === src && + sourceAnchor === srcAnchor && + target === dst && + targetAnchor === dstAnchor + ) { + edgeMap[sourceTarget].push(sedge); + tags[j] = true; + } + } + } + } + + // eslint-disable-next-line + for (const key in edgeMap) { + const arcEdges = edgeMap[key]; + const { length } = arcEdges; + for (let k = 0; k < length; k++) { + const current = arcEdges[k]; + if (current.source === current.target) { + if (loopEdgeType) current.type = loopEdgeType; + // 超过8条自环边,则需要重新处理 + current.loopCfg = { + position: loopPosition[k % 8], + dist: Math.floor(k / 8) * 20 + 50, + }; + continue; + } + if ( + length === 1 && + singleEdgeType && + (current.source !== current.target || current.sourceAnchor !== current.targetAnchor) + ) { + current.type = singleEdgeType; + continue; + } + current.type = multiEdgeType; + const sign = + (k % 2 === 0 ? 1 : -1) * + (reverses[ + `${current.source}|${current.sourceAnchor}|${current.target}|${current.targetAnchor}|${k}` + ] + ? -1 + : 1); + if (length % 2 === 1) { + current.curveOffset = sign * Math.ceil(k / 2) * cod; + } else { + current.curveOffset = sign * (Math.floor(k / 2) * cod + offsetDiff); + } + } + } + return edges; + }; + // 判断两个节点之间是否有边 + const hasEdge = (source, target) => { + const neighbors = source.getNeighbors(); + for (const node of neighbors) { + // 新建边的时候,获取的 neighbors 的数据有问题,不全是 INode 类型,可能没有 getID 方法 + if (node.getID?.() === target.getID?.()) return true; + } + return false; + }; + + // 复制节点 + const cloneElement = (item) => { + let data = graph.save(); + const nodeId = s8(); + data.nodes.push({ + ...item.getModel(), + label: item.getModel().label + '-copy', + x: item.getModel().x + 150, + y: item.getModel().y, + id: item.getModel().component_name + '-' + nodeId, + }); + graph.changeData(data); + }; + + // 获取流水线详情 + const getFirstWorkflow = async (val) => { + const [res] = await to(getWorkflowById(val)); + if (res && res.data) { + setWorkflowInfo(res.data); + const { global_param, dag } = res.data; + setGlobalParam(global_param || []); + if (dag) { + getGraphData(dag); + } + } + }; + + // 打开节点抽屉 + const openNodeDrawer = (node, validate = false) => { + // 获取所有的上游节点 + const parentNodes = findAllParentNodes(graph, node); + // q全局参数 + const globalParams = globalParamRef.current; + // 打开节点编辑抽屉 + propsRef.current.showDrawer(node.getModel(), globalParams, parentNodes, validate); + }; + + // 关闭全局参数节点,获取全局参数 + const closeGlobalParamsDrawer = () => { + const { global_param } = paramsDrawerRef.current.getFieldsValue(); + setGlobalParam(global_param); + closeParamsDrawer(); + }; + + // 初始化图 + const initGraph = () => { + const contextMenu = initMenu(); + G6.registerNode( + 'rect-node', + { + // draw anchor-point circles according to the anchorPoints in afterDraw + getAnchorPoints(cfg) { + return ( + cfg.anchorPoints || [ + // 四个,上下左右 + [0.5, 0], + [0.5, 1], + [0, 0.5], + [1, 0.5], + ] + ); + }, + afterDraw(cfg, group) { + group.addShape('image', { + attrs: { + x: -45, + y: -10, + width: 20, + height: 20, + img: cfg.img, + cursor: 'pointer', + }, + draggable: true, + }); + if (cfg.label) { + group.addShape('text', { + attrs: { + text: fittingString(cfg.label, 70, 10), + x: -20, + y: 0, + fontSize: 10, + textAlign: 'left', + textBaseline: 'middle', + fill: '#000', + cursor: 'pointer', + }, + name: 'text-shape', + draggable: true, + }); + } + if (cfg.formError) { + group.addShape('image', { + attrs: { + x: 43, + y: -24, + width: 18, + height: 18, + img: require('@/assets/img/pipeline-warning.png'), + cursor: 'pointer', + }, + draggable: false, + capture: false, + }); + } + const bbox = group.getBBox(); + if (cfg.formError) { + bbox.y += 6; + bbox.width -= 6; + bbox.height -= 6; + } + const anchorPoints = this.getAnchorPoints(cfg); + anchorPoints.forEach((anchorPos, i) => { + group.addShape('circle', { + attrs: { + r: 3, + x: bbox.x + bbox.width * anchorPos[0], + y: bbox.y + bbox.height * anchorPos[1], + fill: '#fff', + stroke: '#a4a4a5', + cursor: 'crosshair', + lineWidth: 1, + }, + name: `anchor-point`, // the name, for searching by group.find(ele => ele.get('name') === 'anchor-point') + anchorPointIdx: i, // flag the idx of the anchor-point circle + links: 0, // cache the number of edges connected to this shape + visible: false, // invisible by default, shows up when links > 1 or the node is in showAnchors state + draggable: true, + }); + }); + }, + + // response the state changes and show/hide the link-point circles + setState(name, value, item) { + const group = item.getContainer(); + const shape = group.get('children')[0]; + const anchorPoints = group.findAll((item) => item.get('name') === 'anchor-point'); + if (name === 'hover') { + if (value) { + shape.attr('stroke', themes['primaryColor']); + anchorPoints.forEach((point) => { + point.show(); + }); + } else { + shape.attr('stroke', 'transparent'); + anchorPoints.forEach((point) => { + point.hide(); + }); + } + } else if (name === 'drag') { + if (sourceAnchorIdx !== null && sourceAnchorIdx !== undefined) { + const anchorPoint = anchorPoints[sourceAnchorIdx]; + anchorPoint.attr('stroke', value ? themes['primaryColor'] : '#a4a4a5'); + anchorPoint.attr('lineWidth', value ? 2 : 1); + } + } else if (name === 'drop') { + if (dropAnchorIdx !== null && dropAnchorIdx !== undefined) { + const anchorPoint = anchorPoints[dropAnchorIdx]; + anchorPoint.attr('stroke', value ? themes['primaryColor'] : '#a4a4a5'); + anchorPoint.attr('lineWidth', value ? 2 : 1); + } + } + }, + }, + 'rect', + ); + + graph = new G6.Graph({ + container: graphRef.current, + width: graphRef.current.clientWidth || 500, + height: graphRef.current.clientHeight || '100%', + animate: false, + groupByTypes: true, + fitView: true, + plugins: [contextMenu], + enabledStack: false, + fitView: true, + minZoom: 0.5, + maxZoom: 5, + fitViewPadding: 200, + modes: { + default: [ + // config the shouldBegin for drag-node to avoid node moving while dragging on the anchor-point circles + { + type: 'drag-node', + shouldBegin: (e) => { + if (e.target.get('name') === 'anchor-point') return false; + return true; + }, + }, + // config the shouldBegin and shouldEnd to make sure the create-edge is began and ended at anchor-point circles + { + type: 'create-edge', + trigger: 'drag', + shouldBegin: (e) => { + // avoid beginning at other shapes on the node + if (e.target && e.target.get('name') !== 'anchor-point') return false; + sourceAnchorIdx = e.target.get('anchorPointIdx'); + e.target.set('links', e.target.get('links') + 1); // cache the number of edge connected to this anchor-point circle + dragSourceNode = e.item; + return true; + }, + shouldEnd: (e) => { + // avoid ending at other shapes on the node + if (e.target && e.target.get('name') !== 'anchor-point') return false; + if (!dragSourceNode || !e.item) return false; + // 不允许连接自己 + if (dragSourceNode.getID() === e.item.getID()) return false; + // 两个节点不允许多条边 + if (hasEdge(dragSourceNode, e.item)) return false; + if (e.target) { + targetAnchorIdx = e.target.get('anchorPointIdx'); + e.target.set('links', e.target.get('links') + 1); // cache the number of edge connected to this anchor-point circle + return true; + } + targetAnchorIdx = undefined; + return true; + }, + }, + 'drag-canvas', + 'zoom-canvas', + ], + }, + defaultNode: { + type: 'rect-node', + size: [110, 36], + labelCfg: { + style: { + fill: 'transparent', + fontSize: 0, + boxShadow: '0px 0px 12px rgba(75, 84, 137, 0.05)', + overflow: 'hidden', + x: -20, + y: 0, + textAlign: 'left', + textBaseline: 'middle', + }, + }, + style: { + fill: '#fff', + stroke: 'transparent', + cursor: 'pointer', + radius: 8, + shadowColor: 'rgba(75, 84, 137, 0.4)', + shadowBlur: 6, + shadowOffsetX: 0, + shadowOffsetY: 0, + overflow: 'hidden', + lineWidth: 0.5, + }, + }, + defaultEdge: { + // type: 'cubic-vertical', + style: { + endArrow: { + // 设置终点箭头 + path: G6.Arrow.triangle(3, 3, 3), // 使用内置箭头路径函数,参数为箭头的 宽度、长度、偏移量(默认为 0,与 d 对应) + d: 4.5, + fill: '#CDD0DC', + }, + cursor: 'pointer', + lineWidth: 1, + lineAppendWidth: 4, + opacity: 1, + stroke: '#CDD0DC', + radius: 1, + }, + labelCfg: { + autoRotate: true, + style: { + fontSize: 10, + fill: '#FFF', + }, + }, + }, + }); + + // 修改历史数据样式问题 + graph.node((node) => { + return { + style: { + stroke: 'transparent', + radius: 8, + }, + }; + }); + + // 绑定事件 + bindEvents(); + }; + + // 绑定事件 + const bindEvents = () => { + graph.on('node:click', (e) => { + if (e.target.get('name') !== 'anchor-point' && e.item) { + openNodeDrawer(e.item); + } + }); + graph.on('aftercreateedge', (e) => { + // update the sourceAnchor and targetAnchor for the newly added edge + graph.updateItem(e.edge, { + sourceAnchor: sourceAnchorIdx, + targetAnchor: targetAnchorIdx, + type: + targetAnchorIdx === 0 || targetAnchorIdx === 1 ? 'cubic-vertical' : 'cubic-horizontal', + }); + }); + // 删除边时,修改 anchor-point 的 links 值 + graph.on('afterremoveitem', (e) => { + if (e.item && e.item.source && e.item.target) { + const { source, target, sourceAnchor, targetAnchor } = e.item; + const sourceNode = graph.findById(source); + const targetNode = graph.findById(target); + if (sourceNode && !isNaN(sourceAnchor)) { + const sourceAnchorShape = sourceNode + .getContainer() + .find( + (ele) => + ele.get('name') === 'anchor-point' && ele.get('anchorPointIdx') === sourceAnchor, + ); + sourceAnchorShape.set('links', sourceAnchorShape.get('links') - 1); + } + if (targetNode && !isNaN(targetAnchor)) { + const targetAnchorShape = targetNode + .getContainer() + .find( + (ele) => + ele.get('name') === 'anchor-point' && ele.get('anchorPointIdx') === targetAnchor, + ); + targetAnchorShape.set('links', targetAnchorShape.get('links') - 1); + } + } + }); + // after drag on the first node, the edge is created, update the sourceAnchor + graph.on('afteradditem', (e) => { + const sourceAnchor = e.item.getModel().sourceAnchor; + if (e.item && e.item.getType() === 'edge' && !sourceAnchor) { + graph.updateItem(e.item, { + sourceAnchor: sourceAnchorIdx, + }); + } + }); + graph.on('node:mouseenter', (e) => { + graph.setItemState(e.item, 'hover', true); + }); + graph.on('node:mouseleave', (e) => { + graph.setItemState(e.item, 'hover', false); + }); + graph.on('node:dragstart', (e) => { + graph.setItemState(e.item, 'hover', true); + graph.setItemState(e.item, 'drag', true); + }); + graph.on('node:dragend', (e) => { + graph.setItemState(e.item, 'hover', false); + graph.setItemState(e.item, 'drag', false); + }); + graph.on('node:dragenter', (e) => { + if (e.item?.getID() === dragSourceNode?.getID()) return; + graph.setItemState(e.item, 'hover', true); + if (e.target.get('name') === 'anchor-point') { + dropAnchorIdx = e.target.get('anchorPointIdx'); + graph.setItemState(e.item, 'drop', true); + } else { + graph.setItemState(e.item, 'drop', false); + } + }); + graph.on('node:dragleave', (e) => { + if (e.item?.getID() === dragSourceNode?.getID()) return; + graph.setItemState(e.item, 'hover', false); + graph.setItemState(e.item, 'drop', false); + dropAnchorIdx = undefined; + }); + graph.on('node:drop', (e) => { + graph.setItemState(e.item, 'hover', false); + graph.setItemState(e.item, 'drop', false); + dropAnchorIdx = undefined; + }); + }; + + // 上下文菜单 + const initMenu = () => { + const contextMenu = new G6.Menu({ + className: 'pipeline-context-menu', + getContent(evt) { + const type = evt.item.getType(); + const cloneDisplay = type === 'node' ? 'flex' : 'none'; + return ` +
+
+ + + + 复制 +
+
+ + + + 删除 +
+
`; + }, + handleMenuClick: (target, item) => { + const id = target.id; + if (id.startsWith('clone')) { + cloneElement(item); + } else if (id.startsWith('delete')) { + graph.removeItem(item); + } + }, + // offsetX and offsetY include the padding of the parent container + // 需要加上父级容器的 padding-left 16 与自身偏移量 10 + offsetX: 16 + 10, + // 需要加上父级容器的 padding-top 24 、画布兄弟元素高度、与自身偏移量 10 + offsetY: 0, + // the types of items that allow the menu show up + // 在哪些类型的元素上响应 + itemTypes: ['node', 'edge'], + }); + + return contextMenu; + }; + + return ( +
+ +
+
+ + + +
+
+
+ + +
+ ); +}; +export default EditPipeline; diff --git a/react-ui/src/pages/Pipeline/Info/index.less b/react-ui/src/pages/Pipeline/Info/index.less new file mode 100644 index 00000000..5de43e10 --- /dev/null +++ b/react-ui/src/pages/Pipeline/Info/index.less @@ -0,0 +1,64 @@ +.pipeline-container { + display: flex; + height: 100%; + background-color: #fff; + + &__workflow { + flex: 1 1 0; + min-width: 0; + height: 100%; + + &__top { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + height: 52px; + padding: 0 20px; + background: #ffffff; + box-shadow: 0px 3px 6px rgba(146, 146, 146, 0.09); + } + + &__graph { + width: 100%; + height: calc(100% - 52px); + background-color: @background-color; + background-image: url(@/assets/img/pipeline-canvas-bg.png); + background-size: 100% 100%; + } + } +} + +:global { + .pipeline-context-menu { + width: 78px; + padding: 10px 0; + background: #ffffff; + border-radius: 6px; + box-shadow: 0px 0px 6px rgba(40, 84, 168, 0.21); + + &__item { + display: flex; + align-items: center; + width: 100%; + height: 34px; + padding-left: 12px; + color: @text-color-secondary; + font-size: 15px; + cursor: pointer; + + &:hover { + color: #0d5ef8; + font-weight: 500; + background-color: .addAlpha(#8895a8, 0.11) []; + } + + &__icon { + width: 1em; + height: 1em; + margin-right: 9px; + fill: currentColor; + } + } + } +} diff --git a/react-ui/src/pages/Pipeline/Info/utils.tsx b/react-ui/src/pages/Pipeline/Info/utils.tsx new file mode 100644 index 00000000..2feb70c6 --- /dev/null +++ b/react-ui/src/pages/Pipeline/Info/utils.tsx @@ -0,0 +1,86 @@ +import { PipelineGlobalParam, PipelineNodeModelParameter } from '@/types'; +import { Graph, INode } from '@antv/g6'; +import { type MenuProps } from 'antd'; + +// 找到节点所以的上游节点 +export const findAllParentNodes = (graph: Graph, node: INode) => { + const parentNodes: INode[] = []; + let index = -1; + let targetNode = node; + while (targetNode) { + const neighbors: INode[] = graph.getNeighbors(targetNode, 'source'); + for (const sourceNode of neighbors) { + // 避免重复,也避免循环 + const idx = parentNodes.findIndex((item) => sourceNode.getID() === item.getID()); + if (idx === -1 && sourceNode.getID() !== node.getID()) { + parentNodes.push(sourceNode); + } + } + targetNode = parentNodes[++index]; + } + + return parentNodes; +}; + +// 判断并找到全局参数第一个重复项,有重复项时,全局参数不允许保存 +export function findFirstDuplicate(params: PipelineGlobalParam[]): string | null { + const seen = new Set(); + for (const item of params) { + if (seen.has(item.param_name)) { + return item.param_name; + } + seen.add(item.param_name); + } + return null; +} + +// 创建参数下拉菜单 +export function createMenuItems( + params: PipelineGlobalParam[], + parentNodes: INode[], +): MenuProps['items'] { + const nodes: MenuProps['items'] = parentNodes.map((item) => { + const model = item.getModel(); + const out_parametersObj = model.out_parameters as Record; + const outParametersList = Object.keys(out_parametersObj ?? {}); + return { + key: model.id as string, + label: model.label as string, + children: outParametersList.map((key: string) => ({ + key: key as string, + label: out_parametersObj[key].label, + })), + }; + }); + + if (params.length > 0) { + return [ + { + key: 'global', + label: '全局参数', + children: params.map((item) => ({ + key: item.param_name, + label: item.param_name, + })), + }, + ...nodes, + ]; + } else { + return [...nodes]; + } +} + +// 判断是否允许输入 +export function canInput(parameter: PipelineNodeModelParameter) { + const { type, item_type } = parameter; + return !( + type === 'ref' && + (item_type === 'dataset' || + item_type === 'model' || + item_type === 'image' || + item_type === 'code' || + item_type === 'remote-dataset' || + item_type === 'remote-model' || + item_type === 'remote-code') + ); +} diff --git a/react-ui/src/pages/Pipeline/components/GlobalParamsDrawer/index.less b/react-ui/src/pages/Pipeline/components/GlobalParamsDrawer/index.less new file mode 100644 index 00000000..747d4da6 --- /dev/null +++ b/react-ui/src/pages/Pipeline/components/GlobalParamsDrawer/index.less @@ -0,0 +1,29 @@ +.form-item { + position: relative; + padding-top: 40px; + border-bottom: 1px dashed rgba(20, 49, 179, 0.12); + + &__delete-button { + position: absolute; + top: 5px; + right: 24px; + } + + :global { + .anticon.anticon-question-circle { + margin-top: -12px; + } + } +} + +.form-item-add { + margin-top: 15px; + + &:first-child { + margin-top: 0; + } + + &__add-button { + padding: 0; + } +} diff --git a/react-ui/src/pages/Pipeline/components/GlobalParamsDrawer/index.tsx b/react-ui/src/pages/Pipeline/components/GlobalParamsDrawer/index.tsx new file mode 100644 index 00000000..17cdeef9 --- /dev/null +++ b/react-ui/src/pages/Pipeline/components/GlobalParamsDrawer/index.tsx @@ -0,0 +1,205 @@ +import KFIcon from '@/components/KFIcon'; +import { getParamComponent, getParamRules } from '@/pages/Experiment/components/AddExperimentModal'; +import { type PipelineGlobalParam, PipelineGlobalParamType } from '@/types'; +import { to } from '@/utils/promise'; +import { modalConfirm } from '@/utils/ui'; +import { PlusOutlined } from '@ant-design/icons'; +import { Button, Drawer, Form, Input, Radio, Tooltip } from 'antd'; +import { NamePath } from 'antd/es/form/interface'; +import { forwardRef, useImperativeHandle } from 'react'; +import styles from './index.less'; + +type GlobalParamsDrawerProps = { + open: boolean; + onClose: () => void; + globalParam: PipelineGlobalParam[] | null; +}; + +const GlobalParamsDrawer = forwardRef( + ({ open = false, onClose, globalParam = [] }: GlobalParamsDrawerProps, ref) => { + const [form] = Form.useForm(); + + useImperativeHandle( + ref, + () => ({ + validateFields: async () => { + const [values, error] = await to(form.validateFields()); + if (!error && values) { + return values; + } else { + return Promise.reject(error); + } + }, + getFieldsValue: () => { + return form.getFieldsValue(); + }, + }), + [form], + ); + + // 处理参数类型变化 + const handleTypeChange = (name: NamePath) => { + form.setFieldValue(name, null); + }; + + // 处理删除 + const removeParameter = (name: number, remove: (param: number) => void) => { + modalConfirm({ + title: '删除后,该全局参数将不可恢复', + content: '是否确认删除?', + onOk: () => { + remove(name); + }, + }); + }; + + // 处理关闭 + const handleClose = async () => { + try { + await form.validateFields(); + onClose(); + } catch { + return false; + } + }; + + return ( + +
+ + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( +
+ ['global_param', i, 'param_name'])} + rules={[ + { required: true, message: '请输入参数名称' }, + { + validator: (_, value) => { + const list = form.getFieldValue('global_param') || []; + const names = list.filter((item: any) => item?.param_name === value); + if (value && names.length > 1) { + return Promise.reject('参数名称不能重复'); + } else { + return Promise.resolve(); + } + }, + }, + ]} + > + + + + + + + handleTypeChange(['global_param', name, 'param_value'])} + > + 字符串 + 整型 + 布尔类型 + + + + prev.global_param?.[name]?.param_type !== + cur.global_param?.[name]?.param_type + } + > + {({ getFieldValue }) => { + const type = getFieldValue(['global_param', name, 'param_type']); + return ( + <> + + {getParamComponent(type)} + + {/* {type !== 3 && ( + + + + + + + )} */} + + ); + }} + + + + +
+ ))} + + + + + )} +
+ +
+ ); + }, +); + +export default GlobalParamsDrawer; diff --git a/react-ui/src/pages/Pipeline/components/ModelMenu/index.less b/react-ui/src/pages/Pipeline/components/ModelMenu/index.less new file mode 100644 index 00000000..127058a3 --- /dev/null +++ b/react-ui/src/pages/Pipeline/components/ModelMenu/index.less @@ -0,0 +1,54 @@ +.collapse { + flex: none; + width: 250px; + height: 100%; + + :global { + .ant-collapse { + height: calc(100% - 60px); + overflow-y: auto; + background-color: #fff; + border-color: transparent !important; + } + .ant-collapse > .ant-collapse-item > .ant-collapse-header { + margin-bottom: 5px; + padding: 20px 16px 15px 16px; + background-color: #fff; + border-color: transparent; + } + + .ant-collapse > .ant-collapse-item { + margin: 0 10px; + border-bottom: 0.5px dashed rgba(20, 49, 179, 0.12); + border-radius: 0px; + } + .ant-collapse .ant-collapse-content { + padding-bottom: 15px; + border-top: 1px solid transparent; + } + .ant-collapse .ant-collapse-content > .ant-collapse-content-box { + padding: 0; + } + } +} +.collapseItem { + display: flex; + align-items: center; + height: 40px; + padding: 0 16px; + color: @text-color-secondary; + font-size: 14px; + border-radius: 4px; + cursor: pointer; + + &:hover { + color: @primary-color; + background: rgba(22, 100, 255, 0.08); + } +} +.modelMenusTitle { + margin-bottom: 10px; + padding: 12px 25px; + color: #111111; + font-size: 16px; +} diff --git a/react-ui/src/pages/Pipeline/components/ModelMenu/index.tsx b/react-ui/src/pages/Pipeline/components/ModelMenu/index.tsx new file mode 100644 index 00000000..8b420fff --- /dev/null +++ b/react-ui/src/pages/Pipeline/components/ModelMenu/index.tsx @@ -0,0 +1,91 @@ +import { getComponentAll } from '@/services/pipeline/index.js'; +import { PipelineNodeModel } from '@/types'; +import { to } from '@/utils/promise'; +import { Collapse } from 'antd'; +import { useEffect, useState } from 'react'; +import Styles from './index.less'; + +type ModelMenuData = { + key: string; + name: string; + value: PipelineNodeModel[]; +}; + +type ModelMenuProps = { + onComponentDragEnd: ( + data: PipelineNodeModel & { x: number; y: number; label: string; img: string }, + ) => void; +}; +const ModelMenu = ({ onComponentDragEnd }: ModelMenuProps) => { + const [modelMenusList, setModelMenusList] = useState([]); + + useEffect(() => { + // 获取所有组件 + const getAllComponents = async () => { + const [res] = await to(getComponentAll()); + if (res && res.data) { + const menus = res.data as ModelMenuData[]; + setModelMenusList(menus); + } + }; + + getAllComponents(); + }, []); + + const dragEnd = (e: React.DragEvent, data: PipelineNodeModel) => { + onComponentDragEnd({ + ...data, + x: e.clientX, + y: e.clientY, + label: data.component_label, + img: `/assets/images/${data.icon_path}.png`, + }); + }; + + const defaultActiveKey = modelMenusList.map((item) => item.key + ''); + const items = modelMenusList.map((item) => { + return { + key: item.key, + label: item.name, + children: item.value.map((ele) => { + return ( +
{ + dragEnd(e, ele); + }} + className={Styles.collapseItem} + > + {ele.icon_path && ( + + )} + {ele.component_label} +
+ ); + }), + }; + }); + + return ( +
+
组件库
+ {/* 这样 defaultActiveKey 才能生效 */} + {modelMenusList.length > 0 ? ( + + ) : null} +
+ ); +}; + +export default ModelMenu; diff --git a/react-ui/src/pages/Pipeline/components/PipelineNodeDrawer/index.less b/react-ui/src/pages/Pipeline/components/PipelineNodeDrawer/index.less new file mode 100644 index 00000000..bc9e1385 --- /dev/null +++ b/react-ui/src/pages/Pipeline/components/PipelineNodeDrawer/index.less @@ -0,0 +1,59 @@ +.pipeline-drawer { + :global { + label { + width: 100%; + + &::after { + display: none; + } + } + } + + &__title { + display: flex; + align-items: center; + width: 100%; + height: 43px; + margin-bottom: 20px; + padding: 0 24px; + color: @text-color; + font-size: @font-size; + background: #f8fbff; + } + + &__component { + &__select-button { + display: flex; + flex: none; + align-items: center; + justify-content: flex-start; + margin-left: 10px; + padding-right: 0; + padding-left: 0; + } + + &__list-row { + :global { + .ant-row { + padding: 0 !important; + } + } + + &:last-child { + :global { + .ant-form-item { + margin-bottom: 0 !important; + } + } + } + } + + &__add-button { + border-color: .addAlpha(@primary-color, 0.5) []; + box-shadow: none !important; + &:hover { + border-style: solid; + } + } + } +} diff --git a/react-ui/src/pages/Pipeline/components/PipelineNodeDrawer/index.tsx b/react-ui/src/pages/Pipeline/components/PipelineNodeDrawer/index.tsx new file mode 100644 index 00000000..3b0baf90 --- /dev/null +++ b/react-ui/src/pages/Pipeline/components/PipelineNodeDrawer/index.tsx @@ -0,0 +1,735 @@ +import CodeSelectorModal, { CodeConfigData } from '@/components/CodeSelectorModal'; +import KFIcon from '@/components/KFIcon'; +import ParameterInput, { requiredValidator } from '@/components/ParameterInput'; +import ParameterSelect, { + type ParameterSelectDataType, + type ParameterSelectObject, + ParameterSelectTypeList, +} from '@/components/ParameterSelect'; +import ResourceSelectorModal, { + ResourceSelectorType, + selectorTypeConfig, +} from '@/components/ResourceSelectorModal'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { CommonTabKeys, ComponentType } from '@/enums'; +import { canInput, createMenuItems } from '@/pages/Pipeline/Info/utils'; +import { setCurrentType } from '@/state/jcdResource'; +import { + PipelineGlobalParam, + PipelineNodeModel, + PipelineNodeModelParameter, + PipelineNodeModelSerialize, +} from '@/types'; +import { undefinedToNull } from '@/utils'; +import { openAntdModal } from '@/utils/modal'; +import { to } from '@/utils/promise'; +import { removeFormListItem } from '@/utils/ui'; +import { MinusCircleOutlined, PlusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { INode } from '@antv/g6'; +import { Button, Drawer, Flex, Form, Input, MenuProps } from 'antd'; +import { RuleObject } from 'antd/es/form'; +import { NamePath } from 'antd/es/form/interface'; +import { omit } from 'lodash'; +import { forwardRef, useImperativeHandle, useState } from 'react'; +import PropsLabel from '../PropsLabel'; +import styles from './index.less'; + +// 表单列表数据 +export type FormListVariable = { + name: string; // 参数名 + value: string; // 参数值 +}; + +type PipelineNodeParameterProps = { + onFormChange: (data: PipelineNodeModelSerialize) => void; +}; + +const PipelineNodeParameter = forwardRef(({ onFormChange }: PipelineNodeParameterProps, ref) => { + const [form] = Form.useForm(); + const [stagingItem, setStagingItem] = useState( + {} as PipelineNodeModelSerialize, + ); + const [open, setOpen] = useState(false); + const [menuItems, setMenuItems] = useState([]); + + const afterOpenChange = async () => { + if (!open) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_values, error] = await to(form.validateFields()); + // 不管是否验证成功,都需要获取表单数据 + const fields = form.getFieldsValue(); + + // 保持原有字段和顺序 + const task_info = { + ...stagingItem.task_info, + ...fields.task_info, + }; + + const control_strategy = { + ...stagingItem.control_strategy, + ...fields.control_strategy, + }; + + const in_parameters = { + ...stagingItem.in_parameters, + ...fields.in_parameters, + }; + const out_parameters = { + ...stagingItem.out_parameters, + ...fields.out_parameters, + }; + + console.log('getFieldsValue', fields); + + const res = { + ...omit(stagingItem, ['control_strategy', 'task_info', 'in_parameters', 'out_parameters']), + ...omit(fields, ['control_strategy', 'task_info', 'in_parameters', 'out_parameters']), + task_info: task_info, + control_strategy: control_strategy, + in_parameters: in_parameters, + out_parameters: out_parameters, + formError: !!error, + }; + + // ant g6 bug + // 如果值为 undefined 时, graph.changeData(data) 会保留前面的值 + const convertRes = undefinedToNull(res); + console.log('res', convertRes); + onFormChange(convertRes as PipelineNodeModelSerialize); + } + }; + const onClose = () => { + setOpen(false); + }; + + useImperativeHandle( + ref, + () => ({ + showDrawer( + model: PipelineNodeModel, + params: PipelineGlobalParam[], + parentNodes: INode[], + validate: boolean = false, + ) { + try { + setStagingItem({ + ...model, + }); + form.resetFields(); + form.setFieldsValue({ + ...model, + }); + if (validate) { + form.validateFields(); + } + } catch (error) { + console.error('JSON.parse error: ', error); + } + setOpen(true); + + // 参数下拉菜单 + setMenuItems(createMenuItems(params, parentNodes)); + + // 云际组件,设置 store 当前资源类型 + if (model.id.startsWith('remote-task')) { + const resourceType = model.in_parameters['--resource_type'].value; + setCurrentType(resourceType); + } + }, + close: () => { + onClose(); + }, + validateFields: async () => { + if (!open) { + return; + } + const [values, error] = await to(form.validateFields()); + if (!error && values) { + return values; + } else { + form.scrollToField((error as any)?.errorFields?.[0]?.name, { block: 'center' }); + return Promise.reject(error); + } + }, + }), + [form, open], + ); + + // ref 类型选择 + const selectRefData = ( + formItemName: NamePath, + item: PipelineNodeModelParameter | Pick, + ) => { + if (item.item_type === 'code' || item.item_type === 'remote-code') { + selectCodeConfig(formItemName, item); + } else { + selectResource(formItemName, item); + } + }; + + // 选择代码配置 + const selectCodeConfig = ( + formItemName: NamePath, + item: PipelineNodeModelParameter | Pick, + ) => { + const defaultSelected = form.getFieldValue(formItemName)?.value as CodeConfigData; + const { close } = openAntdModal(CodeSelectorModal, { + defaultSelected, + onOk: (res) => { + if (res) { + const { code_repo_name } = res; + form.setFieldValue(formItemName, { + ...item, + value: res, + showValue: code_repo_name, + fromSelect: true, + }); + form.validateFields([formItemName]); + } + close(); + }, + }); + }; + + // 选择数据集、模型、镜像 + const selectResource = ( + formItemName: NamePath, + item: PipelineNodeModelParameter | Pick, + ) => { + let type: ResourceSelectorType; + switch (item.item_type) { + case 'dataset': + case 'remote-dataset': + type = ResourceSelectorType.Dataset; + break; + case 'model': + case 'remote-model': + type = ResourceSelectorType.Model; + break; + default: + type = ResourceSelectorType.Mirror; + break; + } + const fieldValue = form.getFieldValue(formItemName); + const activeTab = fieldValue?.activeTab as CommonTabKeys | undefined; + const expandedKeys = Array.isArray(fieldValue?.expandedKeys) ? fieldValue?.expandedKeys : []; + const checkedKeys = Array.isArray(fieldValue?.checkedKeys) ? fieldValue?.checkedKeys : []; + const { close } = openAntdModal(ResourceSelectorModal, { + type, + defaultExpandedKeys: expandedKeys, + defaultCheckedKeys: checkedKeys, + defaultActiveTab: activeTab, + onOk: (res) => { + if (res) { + if (type === ResourceSelectorType.Mirror) { + const { activeTab, ...rest } = res; + const { url, id, image_id } = rest; + form.setFieldValue(formItemName, { + ...item, + value: rest, + showValue: url, + fromSelect: true, + activeTab, + expandedKeys: [`${image_id}`], + checkedKeys: [`${image_id}-${id}`], + }); + } else { + const { activeTab, ...rest } = res; + const { id, name, version } = rest; + const showValue = `${name}:${version}`; + form.setFieldValue(formItemName, { + ...item, + value: rest, + showValue, + fromSelect: true, + activeTab, + expandedKeys: [id], + checkedKeys: [`${id}-${version}`], + }); + } + } else { + form.setFieldValue(formItemName, { + ...item, + value: undefined, + showValue: undefined, + fromSelect: false, + activeTab: undefined, + expandedKeys: [], + checkedKeys: [], + }); + } + form.validateFields([formItemName]); + close(); + }, + }); + }; + + // 获取选择数据集、模型后面按钮 icon + const getSelectBtnIcon = (item: { item_type: string }) => { + const type = item.item_type; + if (type === 'code' || type === 'remote-code') { + return ; + } + + let selectorType: ResourceSelectorType; + if (type === 'dataset' || type === 'remote-dataset') { + selectorType = ResourceSelectorType.Dataset; + } else if (type === 'model' || type === 'remote-model') { + selectorType = ResourceSelectorType.Model; + } else { + selectorType = ResourceSelectorType.Mirror; + } + + return ; + }; + + // 参数回填 + const handleParameterClick = (name: NamePath, value: any) => { + form.setFieldValue(name, value); + form.validateFields([name]); + }; + + // form item label + const getLabel = ( + item: { key: string; value: PipelineNodeModelParameter }, + parentName: string, + ) => { + return item.value.type === ComponentType.Select || item.value.type === ComponentType.Map ? ( + item.value.label + '(' + item.key + ')' + ) : ( + { + handleParameterClick([parentName, item.key], { + ...item.value, + value, + fromSelect: true, + showValue: value, + }); + }} + /> + ); + }; + + // 模型部署-服务版本验证 + const serviceVersionValidator = (_rule: RuleObject, value: any) => { + // 输入参数值都是对象,如果是手动输入,要求 /^[a-zA-Z0-9._-]+$/ + if ( + typeof value === 'object' && + value && + !value.fromSelect && + value.value && + !/^[a-zA-Z0-9._-]+$/.test(value.value) + ) { + return Promise.reject('服务版本只支持字母、数字、点(.)、下划线(_)、中横线(-)'); + } + + return Promise.resolve(); + }; + + // 必填项校验规则 + const getFormRules = (item: { key: string; value: PipelineNodeModelParameter }) => { + const id = form.getFieldValue('id') as string; + const rules = item.value.require + ? [ + { + validator: requiredValidator, + }, + ] + : []; + + // 模型部署-服务版本验证 + if (id.startsWith('model-deploy') && item.key === '--version') { + rules.push({ + validator: serviceVersionValidator, + }); + } + + return rules; + }; + + // 云际组件,选择类型后,重置镜像和资源,获取镜像、资源列表 + const handleParameterSelect = ( + value: ParameterSelectObject, + itemType: string, + parentName: string, + ) => { + if (itemType === 'remote-resource-type') { + setCurrentType(value.value); + const remoteImage = form.getFieldValue([parentName, '--image']); + form.setFieldValue([parentName, '--image'], { ...remoteImage, value: undefined }); + const remoteResource = form.getFieldValue([parentName, '--resource']); + form.setFieldValue([parentName, '--resource'], { ...remoteResource, value: undefined }); + } + }; + + // 表单组件 + const getFormComponent = ( + item: { key: string; value: PipelineNodeModelParameter }, + parentName: string, + ) => { + return ( + <> + {item.value.type === ComponentType.Ref && ( + + + + + + + + + )} + {item.value.type === ComponentType.Select && + (ParameterSelectTypeList.includes(item.value.item_type as ParameterSelectDataType) ? ( + + + handleParameterSelect( + value as ParameterSelectObject, + item.value.item_type, + parentName, + ) + } + /> + + ) : null)} + {item.value.type === ComponentType.Map && ( + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }, index) => ( + + [ + parentName, + item.key, + 'value', + i, + 'name', + ])} + rules={[ + { + validator: (_, value) => { + if (!value) { + return Promise.reject(new Error('请输入变量名')); + } + if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value)) { + return Promise.reject( + new Error( + '变量名只支持字母、数字、下划线、中横线并且必须以字母或下划线开头', + ), + ); + } + // 判断不能重名 + const list = form + .getFieldValue([parentName, item.key, 'value']) + .filter( + (item: FormListVariable | undefined) => + item !== undefined && item !== null, + ); + + const names = list.map((item: FormListVariable) => item.name); + if (new Set(names).size !== names.length) { + return Promise.reject(new Error('变量名不能重复')); + } + return Promise.resolve(); + }, + }, + ]} + > + + + = + + {/* */} + { + handleParameterClick( + [parentName, item.key, 'value', name, 'value'], + { + ...item.value, + value, + fromSelect: true, + showValue: value, + }, + ); + }} + /> + } + > + + + + {index === fields.length - 1 && ( + + )} + + + ))} + {fields.length === 0 && ( + + )} + + )} + + + )} + {item.value.type === ComponentType.Str && ( + + + + )} + + ); + }; + + // 基本参数 + const basicParametersList = Object.entries(stagingItem.task_info ?? {}) + .map(([key, value]) => ({ + key, + value, + })) + .filter((v) => v.value.visible === true); + + // 控制策略 + const controlStrategyList = Object.entries(stagingItem.control_strategy ?? {}) + .map(([key, value]) => ({ key, value })) + .filter((v) => v.value.visible === true); + + // 输入参数 + const inParametersList = Object.entries(stagingItem.in_parameters ?? {}) + .map(([key, value]) => ({ + key, + value, + })) + .filter((v) => v.value.visible === true); + + // 输出参数 + const outParametersList = Object.entries(stagingItem.out_parameters ?? {}) + .map(([key, value]) => ({ key, value })) + .filter((v) => v.value.visible === true); + + return ( + +
+
+ +
+ + + + + + + + {basicParametersList.length + controlStrategyList.length > 0 && ( +
+ +
+ )} + + {/* 基本参数 */} + {basicParametersList.map((item) => ( + + {getFormComponent(item, 'task_info')} + + ))} + + {/* 控制参数 */} + {controlStrategyList.map((item) => ( + + {getFormComponent(item, 'control_strategy')} + + ))} + + {/* 输入参数 */} + {inParametersList.length > 0 && ( + <> +
+ +
+ {inParametersList.map((item) => ( + + {getFormComponent(item, 'in_parameters')} + + ))} + + )} + + {/* 输出参数 */} + {outParametersList.length > 0 && ( + <> +
+ +
+ {outParametersList.map((item) => ( + + + + ))} + + )} + +
+ ); +}); + +export default PipelineNodeParameter; diff --git a/react-ui/src/pages/Pipeline/components/PropsLabel/index.less b/react-ui/src/pages/Pipeline/components/PropsLabel/index.less new file mode 100644 index 00000000..2df39c02 --- /dev/null +++ b/react-ui/src/pages/Pipeline/components/PropsLabel/index.less @@ -0,0 +1,6 @@ +.props-label { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} diff --git a/react-ui/src/pages/Pipeline/components/PropsLabel/index.tsx b/react-ui/src/pages/Pipeline/components/PropsLabel/index.tsx new file mode 100644 index 00000000..62d1fef0 --- /dev/null +++ b/react-ui/src/pages/Pipeline/components/PropsLabel/index.tsx @@ -0,0 +1,44 @@ +import { Dropdown, type MenuProps } from 'antd'; +import { useEffect } from 'react'; +import styles from './index.less'; + +type PropsLabelProps = { + title: string; + menuItems: MenuProps['items']; + onClick?: (key: string) => void; +}; + +function PropsLabel({ title, menuItems, onClick }: PropsLabelProps) { + useEffect(() => {}, []); + + const handleItemClick: MenuProps['onClick'] = (e) => { + const keyPath = e.keyPath.reverse(); + if (keyPath[0] === 'global') { + onClick?.(`\${${e.key}}`); + } else { + onClick?.(`{{${keyPath.join('.')}}}`); + } + }; + + return ( +
+
{title}
+ {menuItems && menuItems.length > 0 && ( + + e.preventDefault()}>参数 + + )} +
+ ); +} + +export default PropsLabel; diff --git a/react-ui/src/pages/Pipeline/index.jsx b/react-ui/src/pages/Pipeline/index.jsx new file mode 100644 index 00000000..3a1c48a1 --- /dev/null +++ b/react-ui/src/pages/Pipeline/index.jsx @@ -0,0 +1,361 @@ +import KFIcon from '@/components/KFIcon'; +import KFModal from '@/components/KFModal'; +import PageTitle from '@/components/PageTitle'; +import { useCacheState } from '@/hooks/useCacheState'; +import { + addWorkflow, + cloneWorkflow, + editWorkflow, + getWorkflow, + getWorkflowById, + removeWorkflow, +} from '@/services/pipeline/index.js'; +import themes from '@/styles/theme.less'; +import { to } from '@/utils/promise'; +import tableCellRender, { TableCellValueType } from '@/utils/table'; +import { modalConfirm } from '@/utils/ui'; +import { App, Button, ConfigProvider, Form, Input, Space, Table } from 'antd'; +import classNames from 'classnames'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styles from './index.less'; + +const { TextArea } = Input; +const Pipeline = () => { + const [form] = Form.useForm(); + const navigate = useNavigate(); + const [editRecord, setEditRecord] = useState(null); + const [dialogTitle, setDialogTitle] = useState('新建流水线'); + const [pipeList, setPipeList] = useState([]); + const [total, setTotal] = useState(0); + const [isModalOpen, setIsModalOpen] = useState(false); + const [cacheState, setCacheState] = useCacheState(); + const [searchText, setSearchText] = useState(cacheState?.searchText); + const [inputText, setInputText] = useState(cacheState?.searchText); + const [pagination, setPagination] = useState( + cacheState?.pagination ?? { + current: 1, + pageSize: 10, + }, + ); + const { message } = App.useApp(); + + // 获取流水线模板列表 + const getList = useCallback(() => { + const params = { + page: pagination.current - 1, + size: pagination.pageSize, + name: searchText || undefined, + }; + getWorkflow(params).then((res) => { + if (res && res.data && Array.isArray(res.data.content)) { + setPipeList(res.data.content); + setTotal(res.data.totalElements); + } + }); + }, [pagination, searchText]); + + useEffect(() => { + getList(); + }, [getList]); + + // 搜索 + const onSearch = (value) => { + setSearchText(value); + setPagination((prev) => ({ + ...prev, + current: 1, + })); + }; + + // 编辑 + const editTable = (e, record) => { + e.stopPropagation(); + getWorkflowById(record.id).then((ret) => { + if (ret.code === 200) { + form.resetFields(); + form.setFieldsValue({ ...ret.data }); + setEditRecord(ret.data); + setDialogTitle('编辑流水线'); + setIsModalOpen(true); + } + }); + }; + + // 跳转, 缓存当前状态 + const navigateToUrl = (url) => { + setCacheState({ + pagination, + searchText, + }); + navigate(url); + }; + + // 查看详情 + const gotoDetail = (record) => { + navigateToUrl(`/pipeline/template/info/${record.id}`); + }; + + // 显示 modal + const showModal = () => { + form.resetFields(); + setEditRecord(null); + setDialogTitle('新建流水线'); + setIsModalOpen(true); + }; + + // modal 取消 + const handleCancel = () => { + setIsModalOpen(false); + }; + + // 表单提交 + const onFinish = (values) => { + if (editRecord) { + editWorkflow({ ...editRecord, ...values }).then((ret) => { + setIsModalOpen(false); + message.success('编辑成功'); + getList(); + }); + } else { + addWorkflow(values).then((ret) => { + setIsModalOpen(false); + message.success('新建成功'); + if (ret.code === 200) { + navigateToUrl(`/pipeline/template/info/${ret.data.id}`); + } + }); + } + }; + + // 处理删除 + const handlePipelineDelete = (record) => { + modalConfirm({ + title: '删除后,该流水线将不可恢复', + content: '是否确认删除?', + onOk: async () => { + const { id } = record; + const [res] = await to(removeWorkflow(id)); + if (res) { + message.success('删除成功'); + // 如果是一页的唯一数据,删除后,请求第一页的数据 + // 否则直接刷新这一页的数据 + setPagination((prev) => { + return { + ...prev, + current: pipeList.length === 1 ? Math.max(1, prev.current - 1) : prev.current, + }; + }); + } + }, + }); + }; + + // 处理复制 + const handlePipelineCopy = (record) => { + modalConfirm({ + title: '确定复制该条流水线吗?', + okText: '确认', + cancelText: '取消', + isDelete: false, + onOk: async () => { + const { id } = record; + const [res] = await to(cloneWorkflow(id)); + if (res) { + message.success('复制成功'); + getList(); + } + }, + }); + }; + + // 当前页面切换 + const paginationChange = async (current, pageSize) => { + setPagination({ + current, + pageSize, + }); + }; + + const columns = [ + { + title: '序号', + dataIndex: 'index', + key: 'index', + width: 120, + align: 'center', + render: tableCellRender(false, TableCellValueType.Index, { + page: pagination.current - 1, + pageSize: pagination.pageSize, + }), + }, + { + title: '流水线模板名称', + dataIndex: 'name', + key: 'name', + width: '50%', + render: tableCellRender(false, TableCellValueType.Link, { + onClick: gotoDetail, + }), + }, + { + title: '流水线模板描述', + dataIndex: 'description', + key: 'description', + width: '50%', + render: tableCellRender(true), + }, + { + title: '创建时间', + dataIndex: 'create_time', + key: 'create_time', + width: 180, + render: tableCellRender(false, TableCellValueType.Date), + }, + { + title: '修改时间', + dataIndex: 'update_time', + key: 'update_time', + width: 180, + render: tableCellRender(false, TableCellValueType.Date), + }, + { + title: '操作', + key: 'action', + width: 320, + render: (_, record) => ( + + + + + + + + ), + }, + ]; + return ( +
+ +
+
+ setInputText(e.target.value)} + style={{ width: 300 }} + value={inputText} + allowClear + /> + +
+
+
`共${total}条`, + onChange: paginationChange, + }} + rowKey="id" + scroll={{ y: 'calc(100% - 55px)' }} + /> + + + +
+ + + + +