├── .editorconfig ├── .env ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .umirc.ts ├── .vscode └── setting.json ├── LICENSE ├── README.md ├── package.json ├── public ├── favicon.ico ├── less.min.js ├── logo.png └── logo_white.png ├── src ├── components │ ├── CodeBlock │ │ └── index.tsx │ └── Loader │ │ ├── index.less │ │ └── index.tsx ├── db │ └── models │ │ ├── Groups.ts │ │ └── Qas.ts ├── global.less ├── layouts │ ├── components │ │ ├── Header │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── Logo │ │ │ ├── index.less │ │ │ └── index.tsx │ │ └── Modal │ │ │ ├── index.less │ │ │ └── index.tsx │ ├── index.less │ └── index.tsx ├── locales │ ├── en-US.ts │ └── zh-CN.ts ├── models │ ├── app.ts │ └── index.ts ├── pages │ └── index │ │ ├── components │ │ ├── Filter │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── Header │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── Modal │ │ │ ├── index.less │ │ │ └── index.tsx │ │ └── Qas │ │ │ ├── index.less │ │ │ └── index.tsx │ │ └── index.tsx ├── services │ ├── app.ts │ └── index.ts ├── themes │ ├── changeTheme.ts │ ├── index.less │ ├── index.ts │ ├── preset │ │ ├── atom.less │ │ ├── classes.less │ │ ├── helpers │ │ │ ├── bezierEasing.less │ │ │ ├── colorPalette.less │ │ │ └── tinyColor.less │ │ ├── index.less │ │ ├── init.less │ │ └── mixin.less │ └── skins │ │ └── default.less └── utils │ ├── helpers │ └── formatCheckOK.ts │ └── model │ └── index.ts ├── tsconfig.json └── typings.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 6 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | BROWSER=chrome 2 | HOST=0.0.0.0 3 | PORT=8000 -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Deploy gh-pages 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@master 12 | 13 | - name: Build 14 | run: npm install && npm run build 15 | 16 | - name: Deploy 17 | uses: peaceiris/actions-gh-pages@v2 18 | env: 19 | ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }} 20 | PUBLISH_BRANCH: gh-pages 21 | PUBLISH_DIR: ./dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | 16 | # umi 17 | /src/.umi 18 | /src/.umi-production 19 | /src/.umi-test 20 | /.env.local 21 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | import path, { resolve } from 'path' 2 | import { defineConfig } from 'umi' 3 | import OfflinePlugin from 'offline-plugin' 4 | import WebpackPwaManifest from 'webpack-pwa-manifest' 5 | import AntDesignThemePlugin from 'antd-theme-webpack-plugin' 6 | import theme from './src/themes' 7 | 8 | export default defineConfig({ 9 | antd: {}, 10 | cssnano: {}, 11 | theme: theme, 12 | base: '/testool/', 13 | favicon: 'favicon.ico', 14 | publicPath: '/testool/', 15 | dva: { immer: true, hmr: true }, 16 | alias: { R: resolve(__dirname, './') }, 17 | links: [ { rel: 'manifest', href: 'manifest.json' } ], 18 | dynamicImport: { loading: '@/components/Loader/index' }, 19 | locale: { baseNavigator: false, default: 'en-US', antd: true }, 20 | lessLoader: { 21 | javascriptEnabled: true 22 | }, 23 | extraBabelPlugins: [ 24 | [ 25 | 'import', 26 | { 27 | libraryName: 'lodash', 28 | libraryDirectory: '', 29 | camel2DashComponentName: false 30 | } 31 | ] 32 | ], 33 | chainWebpack (memo) { 34 | memo.resolve.alias.set( 35 | 'moment$', 36 | path.resolve(__dirname, 'node_modules/moment/moment.js') 37 | ) 38 | 39 | memo.plugin('antd-theme').use(AntDesignThemePlugin, [ webpack_plugin_antd_theme ]) 40 | memo.plugin('offline-plugin').use(OfflinePlugin, [ webpack_plugin_offline ]) 41 | memo.plugin('webpack-pwa-manifest').use(WebpackPwaManifest, [ webpack_plugin_pwa ]) 42 | } 43 | }) 44 | 45 | const webpack_plugin_offline: any = { 46 | safeToUseOptionalCaches: true, 47 | ServiceWorker: { events: true }, 48 | AppCache: { events: true } 49 | } 50 | 51 | const webpack_plugin_pwa: any = { 52 | name: 'Testool', 53 | short_name: 'Testool', 54 | fingerprints: false, 55 | description: 'A artifact for the test/interview/exam.', 56 | background_color: '#ffffff', 57 | theme_color: '#f44336', 58 | crossorigin: 'use-credentials', 59 | icons: [ 60 | { 61 | src: path.resolve(__dirname, 'public/logo.png'), 62 | sizes: [ 96, 128, 192, 256, 384, 512 ] 63 | }, 64 | { 65 | src: path.resolve(__dirname, 'public/logo_white.png'), 66 | size: '512x512' 67 | } 68 | ] 69 | } 70 | 71 | const webpack_plugin_antd_theme: any = { 72 | antDir: path.join(__dirname, './node_modules/antd'), 73 | stylesDir: path.join(__dirname, './src/themes'), 74 | varFile: path.join(__dirname, './src/themes/skins/default.less'), 75 | mainLessFile: path.join(__dirname, './src/global.less'), 76 | themeVariables: [ 77 | '@text-color', 78 | '@disabled-color', 79 | '@item-hover-bg', 80 | '@component-background', 81 | '@background-color-light', 82 | '@background-color-base', 83 | '@text-color-secondary', 84 | '@body-background', 85 | '@border-color-base', 86 | '@border-color-split' 87 | ], 88 | lessUrl: 'less.min.js', 89 | publicPath: '/testool' 90 | } 91 | -------------------------------------------------------------------------------- /.vscode/setting.json: -------------------------------------------------------------------------------- 1 | { 2 | "path-autocomplete.pathMappings": { 3 | "@": "${folder}/src", 4 | "R": "${folder}" 5 | }, 6 | "path-intellisense.mappings": { 7 | "@": "${workspaceRoot}/src", 8 | "R": "${workspaceRoot}" 9 | } 10 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

logo

3 | 4 | #

testool

5 | 6 | _

A awesome tool for interview & test & exam!

_ 7 | 8 |

9 | attitude_img 10 | style_img 11 | License 12 |

13 | 14 | ## Usage 15 | 16 | https://matrixage.github.io/testool/ 17 | 18 | ## Preview 19 | 20 | ![preview_img](https://s1.ax1x.com/2020/04/03/GUSTPA.png) 21 | 22 | ## Accessibility 23 | 24 | Testool supports the modern browers which support indexeddb and pwa. 25 | 26 | ## License 27 | 28 | Testool is licensed under the MIT license. (http://opensource.org/licenses/MIT) 29 | 30 | ## Contributing 31 | 32 | Welcome advices and pr! 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "name": "testool", 4 | "author": "matrixage", 5 | "license": "MIT", 6 | "private": false, 7 | "main": "src/pages/index/index.tsx", 8 | "description": "A artifact for the test/interview/exam.", 9 | "keywords": [ 10 | "test", 11 | "interview", 12 | "exam", 13 | "react", 14 | "js", 15 | "umi", 16 | "考试", 17 | "面试", 18 | "背题神器", 19 | "背题" 20 | ], 21 | "scripts": { 22 | "start": "umi dev", 23 | "build": "umi build", 24 | "test": "umi-test", 25 | "test:coverage": "umi-test --coverage" 26 | }, 27 | "dependencies": { 28 | "@umijs/preset-react": "1.3.2", 29 | "@umijs/test": "^3.0.9", 30 | "dexie": "^2.0.4", 31 | "dva-model-extend": "^0.1.2", 32 | "is-mobile": "^2.2.1", 33 | "lodash": "^4.17.15", 34 | "moment": "^2.24.0", 35 | "qs": "^6.9.1", 36 | "react": "^16.12.0", 37 | "react-bottom-scroll-listener": "^3.0.0", 38 | "react-dom": "^16.12.0", 39 | "react-helmet": "^5.2.1", 40 | "react-markdown": "^4.3.1", 41 | "react-syntax-highlighter": "^12.2.1", 42 | "react-virtualized": "^9.21.2", 43 | "recharts": "^1.8.5", 44 | "store": "^2.0.12", 45 | "umi": "^3.0.9" 46 | }, 47 | "devDependencies": { 48 | "@types/react-helmet": "^5.0.15", 49 | "@types/react-syntax-highlighter": "^11.0.4", 50 | "@types/react-virtualized": "^9.21.8", 51 | "@types/recharts": "^1.8.9", 52 | "@types/store": "^2.0.2", 53 | "antd-theme-webpack-plugin": "^1.3.1", 54 | "babel-plugin-import": "^1.13.0", 55 | "less-vars-to-js": "^1.3.0", 56 | "offline-plugin": "^5.0.7", 57 | "webpack-pwa-manifest": "^4.2.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatrixAges/testool/ba4c2abdc00bf077a8e3c04a9db6e731949b7b92/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatrixAges/testool/ba4c2abdc00bf077a8e3c04a9db6e731949b7b92/public/logo.png -------------------------------------------------------------------------------- /public/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatrixAges/testool/ba4c2abdc00bf077a8e3c04a9db6e731949b7b92/public/logo_white.png -------------------------------------------------------------------------------- /src/components/CodeBlock/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { connect } from 'umi' 3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' 4 | import { prism, atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism' 5 | 6 | interface IProps { 7 | language?: string 8 | value: string 9 | theme: any 10 | } 11 | 12 | const Index = (props: IProps) => { 13 | const { language, value, theme } = props 14 | 15 | return ( 16 | 17 | {value} 18 | 19 | ) 20 | } 21 | 22 | export default memo(connect(({ app: { theme } }: any) => ({ theme }))(Index)) 23 | -------------------------------------------------------------------------------- /src/components/Loader/index.less: -------------------------------------------------------------------------------- 1 | @keyframes spinner { 2 | 0% { 3 | transform: rotate(0deg); 4 | } 5 | 6 | 100% { 7 | transform: rotate(360deg); 8 | } 9 | } 10 | 11 | ._local { 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | z-index: 100000; 16 | background-color: rgba(255, 255, 255, 0.9); 17 | 18 | :global { 19 | .inner { 20 | width: 40px; 21 | height: 40px; 22 | margin-bottom: 10px; 23 | border-top: 1px solid rgba(0, 0, 0, 0.08); 24 | border-right: 1px solid rgba(0, 0, 0, 0.08); 25 | border-bottom: 1px solid rgba(0, 0, 0, 0.08); 26 | border-left: 1px solid rgba(0, 0, 0, 1); 27 | border-radius: 50%; 28 | z-index: 100001; 29 | :local{ 30 | animation: spinner 600ms infinite linear; 31 | } 32 | } 33 | 34 | .text { 35 | width: 100px; 36 | height: 20px; 37 | text-align: center; 38 | font-size: 14px; 39 | letter-spacing: 2px; 40 | color: black; 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import styles from './index.less' 3 | 4 | const Index = () => { 5 | return ( 6 |
9 |
10 |
loading
11 |
12 | ) 13 | } 14 | 15 | export default memo(Index) 16 | -------------------------------------------------------------------------------- /src/db/models/Groups.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie' 2 | 3 | export interface IGroups { 4 | id?: number 5 | name: string 6 | } 7 | 8 | export default class Groups extends Dexie { 9 | groups: Dexie.Table 10 | 11 | constructor () { 12 | super('Groups') 13 | 14 | this.version(1).stores({ 15 | groups: '++id,name' 16 | }) 17 | 18 | this.groups = this.table('groups') 19 | } 20 | 21 | async init () { 22 | return await this.open() 23 | } 24 | 25 | async addGroup (name: string): Promise { 26 | await this.init() 27 | 28 | await this.transaction('rw', this.groups, async () => { 29 | await this.groups.add({ name }) 30 | }) 31 | } 32 | 33 | async delGroup (name: string): Promise { 34 | let id: number 35 | 36 | const origin_groups = await this.getOriginGroups() 37 | 38 | origin_groups.map((item) => { 39 | if (item.name === name) { 40 | id = item.id 41 | } 42 | }) 43 | 44 | await this.transaction('rw', this.groups, async () => { 45 | await this.groups.delete(id) 46 | }) 47 | } 48 | 49 | async getOriginGroups (): Promise> { 50 | await this.init() 51 | 52 | return await this.groups.toArray() 53 | } 54 | 55 | async getGroups (): Promise> { 56 | const groups = [] 57 | const origin_groups = await this.getOriginGroups() 58 | 59 | origin_groups.map((item) => { 60 | groups.push(item.name) 61 | }) 62 | 63 | return groups 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/db/models/Qas.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie' 2 | 3 | export interface IQas { 4 | id?: number 5 | question: string 6 | answer: string 7 | tags?: Array 8 | rates?: Array 9 | } 10 | 11 | export interface IRates { 12 | rate: number 13 | create_at: number 14 | } 15 | 16 | export interface IRate { 17 | id: number 18 | rate: number 19 | } 20 | 21 | export interface IQuery { 22 | query?: string 23 | times?: number 24 | rate?: number 25 | } 26 | 27 | export default class Qas extends Dexie { 28 | qas: Dexie.Table 29 | 30 | constructor (group_name: string) { 31 | super(group_name) 32 | 33 | this.version(1).stores({ 34 | qas: '++id,question,answer,tags,rates' 35 | }) 36 | 37 | this.qas = this.table('qas') 38 | } 39 | 40 | async init () { 41 | return await this.open() 42 | } 43 | 44 | async addQa ({ question, answer, tags }: IQas): Promise { 45 | await this.init() 46 | 47 | await this.transaction('rw', this.qas, async () => { 48 | await this.qas.add({ question, answer, tags, rates: [] }) 49 | }) 50 | } 51 | 52 | async bulkAddQa (array: Array): Promise { 53 | await this.init() 54 | 55 | const res = await this.transaction('rw', this.qas, async () => { 56 | try { 57 | await this.qas.bulkAdd(array) 58 | 59 | return true 60 | } catch (e) { 61 | return e.message 62 | } 63 | }) 64 | 65 | return res 66 | } 67 | 68 | async delQa (id: number): Promise { 69 | await this.init() 70 | 71 | await this.transaction('rw', this.qas, async () => { 72 | await this.qas.delete(id) 73 | }) 74 | } 75 | 76 | async putQa (id: number, { question, answer, tags }: IQas): Promise { 77 | await this.init() 78 | 79 | await this.transaction('rw', this.qas, async () => { 80 | await this.qas.update(id, { question, answer, tags }) 81 | }) 82 | } 83 | 84 | async query ({ query, times, rate }: IQuery): Promise> { 85 | await this.init() 86 | 87 | if (query) { 88 | return await this.qas 89 | .filter((item) => new RegExp(query).test(item.question)) 90 | .toArray() 91 | } else { 92 | if (times && !rate) { 93 | return await this.qas.filter((item) => item.rates.length < times).toArray() 94 | } 95 | 96 | if (!times && rate) { 97 | return await this.qas 98 | .filter((item) => { 99 | const total: number = item.rates.reduce( 100 | (total: number, curr: IRates) => total + curr.rate, 101 | 0 102 | ) 103 | 104 | return total / item.rates.length <= rate 105 | }) 106 | .toArray() 107 | } 108 | 109 | if (times && rate) { 110 | return await this.qas 111 | .filter((item) => item.rates.length < times) 112 | .filter((item) => { 113 | const total: number = item.rates.reduce( 114 | (total: number, curr: IRates) => total + curr.rate, 115 | 0 116 | ) 117 | 118 | return total / item.rates.length <= rate 119 | }) 120 | .toArray() 121 | } 122 | } 123 | } 124 | 125 | async rate ({ id, rate }: IRate): Promise { 126 | await this.init() 127 | 128 | await this.transaction('rw', this.qas, async () => { 129 | await this.qas 130 | .where('id') 131 | .equals(id) 132 | .modify((item) => 133 | item.rates.push({ rate, create_at: new Date().valueOf() }) 134 | ) 135 | }) 136 | } 137 | 138 | async clearRateLog (id: number): Promise { 139 | await this.init() 140 | 141 | await this.transaction('rw', this.qas, async () => { 142 | await this.qas.update(id, { rates: [] }) 143 | }) 144 | } 145 | 146 | async getQas (page?: number, page_size?: number): Promise> { 147 | await this.init() 148 | 149 | const _page = page ? page - 1 : 0 150 | const _page_size = page_size ? page_size : 10 151 | const _offset = _page * _page_size 152 | 153 | return await this.qas.orderBy('id').offset(_offset).limit(_page_size).toArray() 154 | } 155 | 156 | async getTotal (): Promise { 157 | await this.init() 158 | 159 | const array = await this.qas.toArray() 160 | 161 | return array.length 162 | } 163 | 164 | async getTotalQas (): Promise> { 165 | await this.init() 166 | 167 | return await this.qas.toArray() 168 | } 169 | 170 | async getAverageRate (): Promise { 171 | await this.init() 172 | 173 | const array = await this.qas.toArray() 174 | const length_array = array.length 175 | 176 | let total_all: number = 0 177 | 178 | if (!length_array) return 0 179 | 180 | array.map((item) => { 181 | const length_rates = item.rates.length 182 | 183 | const total_rates: number = item.rates.reduce( 184 | (total: number, curr: IRates) => total + curr.rate, 185 | 0 186 | ) 187 | 188 | if (length_rates) { 189 | total_all = total_all + total_rates / length_rates 190 | } 191 | }) 192 | 193 | return parseFloat((total_all / length_array).toFixed(2)) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/global.less: -------------------------------------------------------------------------------- 1 | @import './themes/index.less'; -------------------------------------------------------------------------------- /src/layouts/components/Header/index.less: -------------------------------------------------------------------------------- 1 | ._local { 2 | height: 56px; 3 | padding: 0 10px; 4 | top: 0; 5 | z-index: 10; 6 | background: var(--primary-color); 7 | color: white; 8 | 9 | &.scrolled { 10 | box-shadow: 0 0 10px #666; 11 | 12 | &.dark { 13 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.4); 14 | } 15 | 16 | @media screen and (max-width:420px) { 17 | box-shadow: none; 18 | } 19 | } 20 | 21 | &.dark { 22 | background-color: var(--color_background); 23 | border-bottom: 1px solid #333; 24 | 25 | :global { 26 | .ant-back-top { 27 | 28 | .ant-back-top-content { 29 | background-color: #333; 30 | } 31 | } 32 | } 33 | } 34 | 35 | :global { 36 | .input_search { 37 | width: 680px; 38 | border: none; 39 | 40 | @media screen and (max-width:840px) { 41 | width: 50vw; 42 | display: none; 43 | } 44 | } 45 | 46 | .left { 47 | left: 10px; 48 | 49 | @media screen and (max-width:840px) { 50 | left: 16px; 51 | } 52 | } 53 | 54 | .right { 55 | right: 10px; 56 | 57 | @media screen and (max-width:840px) { 58 | .btn_search { 59 | display: flex; 60 | } 61 | } 62 | 63 | .icon_wrap { 64 | width: 40px; 65 | height: 40px; 66 | border-radius: var(--radius_normal); 67 | 68 | &:hover { 69 | background-color: var(--light_bg_hover); 70 | } 71 | } 72 | } 73 | 74 | .ant-input-affix-wrapper { 75 | background-color: rgba(0, 0, 0, 0.22); 76 | transition: all ease 0.3s; 77 | 78 | &:hover { 79 | background-color: rgba(0, 0, 0, 0.4); 80 | } 81 | 82 | .ant-input-prefix { 83 | margin-left: 4px; 84 | margin-right: 10px; 85 | } 86 | 87 | .ant-input-suffix { 88 | svg { 89 | color: white; 90 | } 91 | } 92 | 93 | input::-webkit-input-placeholder { 94 | color: rgba(255, 255, 255, 0.8); 95 | } 96 | 97 | input.ant-input { 98 | background-color: transparent; 99 | color: white; 100 | } 101 | } 102 | 103 | .ant-back-top { 104 | @media screen and (max-width:840px) { 105 | width: 30px !important; 106 | height: 30px !important; 107 | bottom: 15px !important; 108 | right: calc(~'50vw - 15px') !important; 109 | } 110 | 111 | .ant-back-top-content { 112 | width: 30px !important; 113 | height: 30px !important; 114 | background-color: #ddd; 115 | margin-right: 0; 116 | border-radius: var(--radius_normal); 117 | display: flex; 118 | justify-content: center; 119 | align-items: center; 120 | 121 | &:hover { 122 | background-color: var(--primary-color); 123 | } 124 | 125 | .ant-back-top-icon { 126 | margin: 0; 127 | background-size: 100% 100%; 128 | } 129 | } 130 | } 131 | } 132 | } 133 | 134 | .placeholder { 135 | height: 56px; 136 | } -------------------------------------------------------------------------------- /src/layouts/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, Fragment, useState, useEffect } from 'react' 2 | import { connect, useIntl } from 'umi' 3 | import { Input, BackTop } from 'antd' 4 | import { SearchOutlined, SettingOutlined, GithubOutlined } from '@ant-design/icons' 5 | import Logo from '../Logo' 6 | import Modal from '../Modal' 7 | import styles from './index.less' 8 | 9 | const Index = (props: any) => { 10 | const { dispatch, app: { groups, analysis_data, theme, current_group } } = props 11 | const [ state_scrolled, setStateScrolled ] = useState(false) 12 | const [ state_modal_visible, setStateModalVisible ] = useState(false) 13 | const [ state_search_display, setStateSearchDisplay ] = useState({}) 14 | const lang = useIntl() 15 | 16 | useEffect(() => { 17 | const setScrolled = (e: any) => { 18 | if (e.srcElement['documentElement'].scrollTop) { 19 | setStateScrolled(true) 20 | } else { 21 | setStateScrolled(false) 22 | } 23 | } 24 | 25 | window.addEventListener('scroll', setScrolled) 26 | 27 | return () => { 28 | window.removeEventListener('scroll', setScrolled) 29 | } 30 | }, []) 31 | 32 | const onDeleteGroup = (group: string) => { 33 | dispatch({ 34 | type: 'app/deleteGroup', 35 | payload: { 36 | group, 37 | message_success: lang.formatMessage({ 38 | id: 'layout.modal.clear.message_success' 39 | }), 40 | message_failed: lang.formatMessage({ 41 | id: 'layout.modal.clear.message_failed' 42 | }) 43 | } 44 | }) 45 | 46 | setStateModalVisible(false) 47 | } 48 | 49 | const searchQaByQuestion = (e: any) => { 50 | if (e.target.value) { 51 | dispatch({ 52 | type: 'index/queryQa', 53 | payload: { 54 | current_group, 55 | params: { 56 | query: e.target.value 57 | } 58 | } 59 | }) 60 | 61 | dispatch({ 62 | type: 'index/updateState', 63 | payload: { querying: true } 64 | }) 65 | } else { 66 | dispatch({ 67 | type: 'index/query', 68 | payload: { 69 | current_group 70 | } 71 | }) 72 | 73 | dispatch({ 74 | type: 'index/updateState', 75 | payload: { 76 | no_more: false, 77 | querying: false 78 | } 79 | }) 80 | } 81 | } 82 | 83 | return ( 84 | 85 |
93 |
94 | 95 |
96 | } 100 | style={{ ...state_search_display }} 101 | allowClear={true} 102 | maxLength={16} 103 | size='large' 104 | type='search' 105 | onChange={searchQaByQuestion} 106 | /> 107 |
108 | {!state_search_display['display'] && ( 109 |
{ 112 | setStateSearchDisplay({ display: 'flex' }) 113 | }} 114 | > 115 | 116 |
117 | )} 118 | 124 | 125 | 126 |
{ 129 | setStateModalVisible(true) 130 | }} 131 | > 132 | 133 |
134 |
135 | 136 |
137 |
138 | { 145 | setStateModalVisible(false) 146 | }} 147 | onDeleteGroup={onDeleteGroup} 148 | setStateModalVisible={setStateModalVisible} 149 | /> 150 | 151 | ) 152 | } 153 | 154 | export default memo(connect(({ app }: any) => ({ app }))(Index)) 155 | -------------------------------------------------------------------------------- /src/layouts/components/Logo/index.less: -------------------------------------------------------------------------------- 1 | ._local { 2 | width: 40em; 3 | height: 30em; 4 | padding: 2em 0; 5 | 6 | &.main { 7 | background-color: var(--primary-color); 8 | 9 | :global { 10 | .left,.right { 11 | width: calc(~'(100% - 1em) / 2'); 12 | } 13 | 14 | .line_item { 15 | width: 10em; 16 | height: 1.6em; 17 | background-color: white; 18 | } 19 | 20 | .line_item:nth-of-type(2) { 21 | margin: 5em; 22 | } 23 | 24 | .center { 25 | width: 1em; 26 | height: 100%; 27 | background-color: white; 28 | } 29 | } 30 | } 31 | 32 | &.white { 33 | background-color: white; 34 | 35 | :global { 36 | .left,.right { 37 | width: calc(~'(100% - 1em) / 2'); 38 | } 39 | 40 | .line_item { 41 | width: 10em; 42 | height: 1.6em; 43 | background-color: var(--primary-color); 44 | } 45 | 46 | .line_item:nth-of-type(2) { 47 | margin: 5em; 48 | } 49 | 50 | .center { 51 | width: 1em; 52 | height: 100%; 53 | background-color: var(--primary-color); 54 | } 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /src/layouts/components/Logo/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import styles from './index.less' 3 | 4 | interface IProps { 5 | type?: 'main' | 'white' 6 | size?: number 7 | } 8 | 9 | const Index = (props: IProps) => { 10 | const { type = 'main', size = 1.6 } = props 11 | 12 | return ( 13 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | ) 30 | } 31 | 32 | export default memo(Index) 33 | -------------------------------------------------------------------------------- /src/layouts/components/Modal/index.less: -------------------------------------------------------------------------------- 1 | ._local { 2 | &.dark { 3 | :global { 4 | .settings_wrap { 5 | background-color: var(--color_background); 6 | 7 | .setting_item { 8 | border-top: 1px solid #242424; 9 | } 10 | } 11 | } 12 | } 13 | 14 | :global { 15 | .ant-modal-content { 16 | overflow: hidden; 17 | } 18 | 19 | .ant-modal-header { 20 | padding: 16px 20px; 21 | } 22 | 23 | .ant-modal-body { 24 | padding: 0; 25 | } 26 | 27 | .settings_wrap { 28 | background-color: white; 29 | 30 | .setting_item { 31 | padding: 12px 20px; 32 | border-top: 1px solid whitesmoke; 33 | } 34 | 35 | .option_items { 36 | .option_item { 37 | flex: 1; 38 | padding: 24px 0; 39 | padding-bottom: 20px; 40 | border-radius: var(--radius_normal); 41 | 42 | &:hover { 43 | background-color: var(--light_bg_hover); 44 | } 45 | 46 | .name { 47 | margin-top: 6px; 48 | } 49 | } 50 | } 51 | } 52 | 53 | .clear_wrap { 54 | padding: 20px; 55 | } 56 | 57 | .analysis_wrap { 58 | padding: 20px; 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/layouts/components/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState, useEffect } from 'react' 2 | import { useIntl, getLocale, setLocale } from 'umi' 3 | import { Modal, Switch, Select, Empty, message } from 'antd' 4 | import { PieChartOutlined, ImportOutlined, ExportOutlined, ClearOutlined } from '@ant-design/icons' 5 | import { ResponsiveContainer, ComposedChart, XAxis, YAxis, Tooltip, Bar, Line } from 'recharts' 6 | import store from 'store' 7 | import { IGetAnalysisData } from '@/services/app' 8 | import changeTheme from '@/themes/changeTheme' 9 | import formatCheckOK from '@/utils/helpers/formatCheckOK' 10 | import styles from './index.less' 11 | 12 | const { Option } = Select 13 | 14 | interface IProps { 15 | theme: string 16 | groups: Array 17 | analysis_data: Array 18 | visible: boolean 19 | dispatch: (params: any) => void 20 | onCancel: () => void 21 | onDeleteGroup: (group: string) => void 22 | setStateModalVisible: (visible: boolean) => void 23 | } 24 | 25 | const Index = (props: IProps) => { 26 | const { 27 | theme, 28 | dispatch, 29 | groups, 30 | analysis_data, 31 | visible, 32 | onCancel, 33 | onDeleteGroup, 34 | setStateModalVisible 35 | } = props 36 | const lang = useIntl() 37 | const [ state_modal_type, setStateModalType ] = useState('settings') 38 | const [ state_group_selected, setStateGroupSelected ] = useState('') 39 | 40 | useEffect( 41 | () => { 42 | setStateModalType('settings') 43 | }, 44 | [ visible ] 45 | ) 46 | 47 | const props_modal = { 48 | visible, 49 | onCancel, 50 | centered: true, 51 | maskClosable: true, 52 | destroyOnClose: true, 53 | title: lang.formatMessage({ id: 'layout.modal.title' }) 54 | } 55 | 56 | const onChangeLang = (v: boolean): void => { 57 | if (v) { 58 | setLocale('en-US', false) 59 | } else { 60 | setLocale('zh-CN', false) 61 | } 62 | } 63 | 64 | const onChangeTheme = (v: boolean): void => { 65 | let _theme: 'light' | 'dark' = v ? 'light' : 'dark' 66 | 67 | store.set('theme', _theme) 68 | changeTheme(_theme) 69 | 70 | dispatch({ 71 | type: 'app/updateState', 72 | payload: { theme: _theme } 73 | }) 74 | } 75 | 76 | const onChangeLoadway = (v: boolean): void => { 77 | let _loadway: string = v ? 'scroll' : 'page' 78 | 79 | store.set('loadway', _loadway) 80 | 81 | dispatch({ 82 | type: 'app/updateState', 83 | payload: { loadway: _loadway } 84 | }) 85 | } 86 | 87 | const onChangeModalType = (type: string): void => { 88 | if (type === 'analysis') { 89 | dispatch({ type: 'app/getAnalysisData' }) 90 | } 91 | 92 | if (type === 'import') { 93 | const message_success = lang.formatMessage({ 94 | id: 'layout.modal.import.message_success' 95 | }) 96 | const message_failed = lang.formatMessage({ 97 | id: 'layout.modal.import.message_failed' 98 | }) 99 | 100 | const upload_anchor = document.getElementById('upload_anchor_of_data') 101 | 102 | upload_anchor.click() 103 | upload_anchor.addEventListener('change', function (){ 104 | const _that: any = this 105 | const files = _that.files 106 | 107 | if (!files.length) return 108 | 109 | const data = files[0] 110 | const reader = new FileReader() 111 | 112 | reader.readAsText(data) 113 | reader.onload = (e) => { 114 | const result: any = e.target.result 115 | 116 | try { 117 | const target_data = JSON.parse(result) 118 | 119 | if (!formatCheckOK(target_data)) { 120 | message.error(message_failed) 121 | } else { 122 | dispatch({ 123 | type: 'app/importData', 124 | payload: { 125 | data: target_data, 126 | message_success 127 | } 128 | }) 129 | 130 | setStateModalVisible(false) 131 | } 132 | } catch (_) { 133 | message.error(message_failed) 134 | } 135 | } 136 | }) 137 | 138 | return 139 | } 140 | 141 | if (type === 'export') { 142 | dispatch({ type: 'app/exportData' }) 143 | 144 | return 145 | } 146 | 147 | setStateModalType(type) 148 | } 149 | 150 | if (state_modal_type === 'settings') { 151 | return ( 152 | 157 |
158 |
159 |
onChangeModalType('analysis')} 162 | > 163 | 164 | 165 | {lang.formatMessage({ 166 | id: 'layout.modal.name_analysis' 167 | })} 168 | 169 |
170 | 176 |
onChangeModalType('import')} 179 | > 180 | 181 | 182 | {lang.formatMessage({ 183 | id: 'layout.modal.name_import' 184 | })} 185 | 186 |
187 | 188 |
onChangeModalType('export')} 191 | > 192 | 193 | 194 | {lang.formatMessage({ 195 | id: 'layout.modal.name_export' 196 | })} 197 | 198 |
199 |
onChangeModalType('clear')} 202 | > 203 | 204 | 205 | {lang.formatMessage({ 206 | id: 'layout.modal.name_clear' 207 | })} 208 | 209 |
210 |
211 |
212 |
213 | 214 | {lang.formatMessage({ 215 | id: 'layout.modal.name_language' 216 | })} 217 | 218 | 224 |
225 |
226 | 227 | {lang.formatMessage({ 228 | id: 'layout.modal.name_theme' 229 | })} 230 | 231 | 241 |
242 |
243 | 244 | {lang.formatMessage({ 245 | id: 'layout.modal.name_loadway' 246 | })} 247 | 248 | 258 |
259 |
260 |
261 |
262 | ) 263 | } 264 | 265 | if (state_modal_type === 'clear') { 266 | return ( 267 | onDeleteGroup(state_group_selected)} 275 | > 276 |
277 | 292 |
293 |
294 | ) 295 | } 296 | 297 | if (state_modal_type === 'analysis') { 298 | return ( 299 | 307 |
308 | {analysis_data.length > 0 ? ( 309 | 310 | 311 | 312 | 313 | 314 | 319 | 324 | 325 | 326 | ) : ( 327 |
328 | 329 |
330 | )} 331 |
332 |
333 | ) 334 | } 335 | } 336 | 337 | export default memo(Index) 338 | -------------------------------------------------------------------------------- /src/layouts/index.less: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 680px; 3 | padding: 30px 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | @media screen and (max-width:840px) { 8 | .container { 9 | width: 100%; 10 | padding: 30px 12px; 11 | padding-bottom: 60px; 12 | } 13 | } 14 | 15 | .dark { 16 | color: #ffffff; 17 | } -------------------------------------------------------------------------------- /src/layouts/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, Fragment } from 'react' 2 | import { connect, useIntl } from 'umi' 3 | import { Helmet } from 'react-helmet' 4 | import Header from './components/Header' 5 | import changeTheme from '@/themes/changeTheme' 6 | import styles from './index.less' 7 | 8 | import * as OfflinePluginRuntime from 'offline-plugin/runtime' 9 | OfflinePluginRuntime.install() 10 | 11 | const Index = (props: any) => { 12 | const { children, theme } = props 13 | const lang = useIntl() 14 | 15 | if (process.env.NODE_ENV !== 'development') { 16 | changeTheme(theme) 17 | } 18 | 19 | return ( 20 | 21 | 22 | {lang.formatMessage({ id: 'site.title' })} 23 | 24 |
25 |
31 |
{children}
32 |
33 | 34 | ) 35 | } 36 | 37 | export default memo(connect(({ app: { theme } }: any) => ({ theme }))(Index)) 38 | -------------------------------------------------------------------------------- /src/locales/en-US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'site.title': 'Testool', 3 | 'common.btn_ok': 'OK', 4 | 'common.btn_cancel': 'Cancel', 5 | 'common.btn_delete': 'Delete', 6 | 'common.confirm': 'Confirm', 7 | 'header.search.placeholder': 'Search in QA', 8 | 'layout.modal.title': 'Settings', 9 | 'layout.modal.name_language': 'Language', 10 | 'layout.modal.name_theme': 'Theme', 11 | 'layout.modal.theme_dark': 'dark', 12 | 'layout.modal.theme_light': 'light', 13 | 'layout.modal.name_loadway': 'Loading Way', 14 | 'layout.modal.loadway_scroll': 'scroll', 15 | 'layout.modal.loadway_page': 'page', 16 | 'layout.modal.name_analysis': 'Analysis', 17 | 'layout.modal.name_import': 'Import', 18 | 'layout.modal.name_export': 'Export', 19 | 'layout.modal.name_clear': 'Clear', 20 | 'layout.modal.clear.select.placeholder': 'Choose a group to delete', 21 | 'layout.modal.clear.message_success': 'delete success', 22 | 'layout.modal.clear.message_failed': 'delete failed', 23 | 'layout.modal.import.message_success': 'import success', 24 | 'layout.modal.import.message_failed': 'import failed,please check data format.', 25 | 'index.btn_pass': 'Pass', 26 | 'index.btn_add': 'Add Group', 27 | 'index.header.tooltip.group': 'toggle/add group', 28 | 'index.header.tooltip.filter': 'show the filter', 29 | 'index.header.tooltip.add_qa': 'add QA', 30 | 'index.filter.times.placeholder': 'choose pass times', 31 | 'index.filter.times': 'times', 32 | 'index.filter.rate.placeholder': 'choose rate value', 33 | 'index.filter.below': 'below', 34 | 'index.modal.title.add_group': 'Groups', 35 | 'index.modal.title.add_qa': 'Add QA', 36 | 'index.modal.title.edit_qa': 'Edit QA', 37 | 'index.modal.title.rate_log': 'Rate logs', 38 | 'index.modal.add_group.placeholder': 'toggle/add group', 39 | 'index.modal.add_group.success': 'add group success', 40 | 'index.modal.add_group.failed': 'add group failed', 41 | 'index.modal.add_group.repeat': 'group has exsited', 42 | 'index.modal.add_qa.question.placeholder': 'Please input the question', 43 | 'index.modal.add_qa.answer.placeholder': 'Please input the answer (supported markdown)', 44 | 'index.modal.add_qa.tags.placeholder': 'Please add tags (at least 2)', 45 | 'index.modal.add_qa.tags.count.warn': 'at most add 5 tags', 46 | 'index.modal.add_qa.tags.length.warn': 'tag at most length 12', 47 | 'index.modal.add_qa.success': 'add qa success', 48 | 'index.modal.add_qa.failed': 'add qa failed', 49 | 'index.modal.rate.success': 'rate success', 50 | 'index.modal.rate.failed': 'rate failed', 51 | 'index.modal.edit_qa.success': 'edit qa success', 52 | 'index.modal.edit_qa.failed': 'edit qa failed', 53 | 'index.modal.edit_qa.delete.confirm': 'confirm delete this QA?', 54 | 'index.modal.edit_qa.delete.success': 'delete qa success', 55 | 'index.modal.edit_qa.delete.failed': 'delete qa failed', 56 | 'index.modal.clear_log.confirm': 'confirm clear rate log of this QA?', 57 | 'index.modal.clear_log.success': 'clear qa rate log success', 58 | 'index.modal.clear_log.failed': 'clear qa rate log failed', 59 | 'index.no_more': 'no more' 60 | } 61 | -------------------------------------------------------------------------------- /src/locales/zh-CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'site.title': '复习神器', 3 | 'common.btn_ok': '确定', 4 | 'common.btn_cancel': '取消', 5 | 'common.btn_delete': '删除', 6 | 'common.confirm': '确认', 7 | 'header.search.placeholder': '在QA中搜索', 8 | 'layout.modal.title': '设置', 9 | 'layout.modal.name_language': '语言', 10 | 'layout.modal.name_theme': '主题', 11 | 'layout.modal.theme_dark': '浅色', 12 | 'layout.modal.theme_light': '深色', 13 | 'layout.modal.name_loadway': '加载方式', 14 | 'layout.modal.loadway_scroll': '滚动', 15 | 'layout.modal.loadway_page': '分页', 16 | 'layout.modal.name_analysis': '分析', 17 | 'layout.modal.name_import': '导入', 18 | 'layout.modal.name_export': '导出', 19 | 'layout.modal.name_clear': '清除', 20 | 'layout.modal.clear.select.placeholder': '选择一个分组进行删除', 21 | 'layout.modal.clear.message_success': '删除成功', 22 | 'layout.modal.clear.message_failed': '删除失败', 23 | 'layout.modal.import.message_success': '导入成功', 24 | 'layout.modal.import.message_failed': '导入失败,请检查数据格式', 25 | 'index.btn_pass': '通过', 26 | 'index.btn_add': '添加分组', 27 | 'index.header.tooltip.group': '切换/添加 分组', 28 | 'index.header.tooltip.filter': '展示筛选条件', 29 | 'index.header.tooltip.add_qa': '添加 QA', 30 | 'index.filter.times.placeholder': '选择通过次数', 31 | 'index.filter.times': '次', 32 | 'index.filter.rate.placeholder': '选择评分', 33 | 'index.filter.below': '低于', 34 | 'index.modal.title.add_group': '分组', 35 | 'index.modal.title.add_qa': '添加 QA', 36 | 'index.modal.title.edit_qa': '编辑 QA', 37 | 'index.modal.title.rate_log': '评分记录', 38 | 'index.modal.add_group.placeholder': '切换/添加 分组', 39 | 'index.modal.add_group.success': '分组添加成功', 40 | 'index.modal.add_group.failed': '分组添加失败', 41 | 'index.modal.add_group.repeat': '分组已经存在', 42 | 'index.modal.add_qa.question.placeholder': '请输入问题', 43 | 'index.modal.add_qa.answer.placeholder': '请输入答案(支持markdown)', 44 | 'index.modal.add_qa.tags.placeholder': '请添加标签(至少两个)', 45 | 'index.modal.add_qa.tags.count.warn': '最多添加5个标签', 46 | 'index.modal.add_qa.tags.length.warn': '标签不超过12个字符', 47 | 'index.modal.add_qa.success': 'QA添加成功', 48 | 'index.modal.add_qa.failed': 'QA添加失败', 49 | 'index.modal.rate.success': '评分成功', 50 | 'index.modal.rate.failed': '评分失败', 51 | 'index.modal.edit_qa.success': '修改成功', 52 | 'index.modal.edit_qa.failed': '修改失败', 53 | 'index.modal.edit_qa.delete.confirm': '确认删除该条QA?', 54 | 'index.modal.edit_qa.delete.success': '删除成功', 55 | 'index.modal.edit_qa.delete.failed': '删除失败', 56 | 'index.modal.clear_log.confirm': '确认清除这条QA的评分记录?', 57 | 'index.modal.clear_log.success': '清除评分记录成功', 58 | 'index.modal.clear_log.failed': '清除评分记录失败', 59 | 'index.no_more': '没有更多了' 60 | } 61 | -------------------------------------------------------------------------------- /src/models/app.ts: -------------------------------------------------------------------------------- 1 | import { message } from 'antd' 2 | import store from 'store' 3 | import { 4 | Service_addTableGroups, 5 | Service_getAllGroups, 6 | Service_deleteGroup, 7 | Service_getAnalysisData, 8 | Service_importData, 9 | Service_exportData 10 | } from '@/services/app' 11 | 12 | export default { 13 | namespace: 'app', 14 | 15 | state: { 16 | groups: [], 17 | current_group: '', 18 | theme: store.get('theme') ? store.get('theme') : 'light', 19 | loadway: store.get('loadway'), 20 | analysis_data: [] 21 | }, 22 | 23 | subscriptions: { 24 | setup ({ dispatch, history }) { 25 | history.listen((location: any) => { 26 | dispatch({ 27 | type: 'query', 28 | payload: { 29 | ...location.query 30 | } 31 | }) 32 | }) 33 | } 34 | }, 35 | 36 | effects: { 37 | *query ({ payload }, { call, put, select }) { 38 | const logs = yield call(Service_addTableGroups) 39 | 40 | if (!logs) return 41 | 42 | const groups = yield call(Service_getAllGroups) 43 | 44 | if (groups.length === 0) { 45 | store.set('current_group', null) 46 | } 47 | 48 | const c_group = store.get('current_group') 49 | 50 | yield put({ 51 | type: 'updateState', 52 | payload: { 53 | groups, 54 | current_group: 55 | groups.indexOf(c_group) === -1 || 56 | c_group === null || 57 | c_group === undefined 58 | ? groups[0] 59 | : c_group 60 | } 61 | }) 62 | 63 | const { current_group } = yield select(({ app }) => app) 64 | 65 | if (!current_group) return 66 | 67 | yield put({ 68 | type: 'index/query', 69 | payload: { current_group, ...payload } 70 | }) 71 | }, 72 | *deleteGroup ({ payload }, { call, put }) { 73 | const { group, message_success, message_failed } = payload 74 | const res = yield call(Service_deleteGroup, group) 75 | 76 | if (res) { 77 | message.success(message_success) 78 | } else { 79 | message.error(message_failed) 80 | } 81 | 82 | yield put({ type: 'query' }) 83 | }, 84 | *getAnalysisData ({}, { call, put }) { 85 | const res = yield call(Service_getAnalysisData) 86 | 87 | yield put({ type: 'updateState', payload: { analysis_data: res } }) 88 | }, 89 | *importData ({ payload }, { call, put }) { 90 | const { data, message_success } = payload 91 | 92 | const res = yield call(Service_importData, data) 93 | 94 | if (res === true) { 95 | message.success(message_success) 96 | 97 | yield put({ type: 'query' }) 98 | } else { 99 | message.error(res) 100 | } 101 | }, 102 | *exportData ({}, { call }) { 103 | const res = yield call(Service_exportData) 104 | 105 | const data = `data:text/json;charset=utf-8,${encodeURIComponent( 106 | JSON.stringify(res) 107 | )}` 108 | const download_anchor = document.getElementById('download_anchor_of_data') 109 | 110 | download_anchor.setAttribute('href', data) 111 | download_anchor.setAttribute('download', 'testool_data.json') 112 | 113 | download_anchor.click() 114 | } 115 | }, 116 | 117 | reducers: { 118 | updateState (state, { payload }) { 119 | return { 120 | ...state, 121 | ...payload 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | import modelExtend from 'dva-model-extend' 2 | import pageModel from '@/utils/model' 3 | import { message } from 'antd' 4 | import cloneDeep from 'lodash/cloneDeep' 5 | import { 6 | Service_addGroup, 7 | Service_addQa, 8 | Service_delQa, 9 | Service_putQa, 10 | Service_query, 11 | Service_getQas, 12 | Service_getTotal, 13 | Service_rate, 14 | Service_clearRateLog 15 | } from '@/services/index' 16 | 17 | export default modelExtend(pageModel, { 18 | namespace: 'index', 19 | 20 | state: { 21 | modal_visible: false, 22 | modal_type: '', 23 | filter_visible: false, 24 | qas: [], 25 | total: 0, 26 | no_more: false, 27 | current_item: {}, 28 | current_id: 0, 29 | current_index: 0, 30 | querying: false 31 | }, 32 | 33 | subscriptions: {}, 34 | 35 | effects: { 36 | *query ({ payload }, { call, put }) { 37 | const { current_group, page } = payload 38 | 39 | if (!current_group) return 40 | 41 | const qas = yield call(Service_getQas, current_group, page) 42 | const total = yield call(Service_getTotal, current_group) 43 | 44 | yield put({ 45 | type: 'updateState', 46 | payload: { qas, total } 47 | }) 48 | }, 49 | *loadMore ({ payload }, { call, put, select }) { 50 | const { qas } = yield select(({ index }) => index) 51 | const { current_group, page, page_size } = payload 52 | 53 | const res = yield call(Service_getQas, current_group, page, page_size) 54 | 55 | if (res.length === 0) { 56 | yield put({ 57 | type: 'updateState', 58 | payload: { no_more: true } 59 | }) 60 | 61 | return 62 | } 63 | 64 | yield put({ 65 | type: 'updateState', 66 | payload: { qas: qas.concat(res) } 67 | }) 68 | }, 69 | *addGroup ({ payload }, { call, put }) { 70 | const { name, message_success, message_failed } = payload 71 | 72 | const res = yield call(Service_addGroup, name) 73 | 74 | if (res) { 75 | message.success(message_success) 76 | } else { 77 | message.error(message_failed) 78 | } 79 | 80 | yield put({ type: 'app/query' }) 81 | 82 | yield put({ 83 | type: 'updateState', 84 | payload: { modal_visible: false } 85 | }) 86 | }, 87 | *addQa ({ payload }, { call, put }) { 88 | const { current_group, params, message_success, message_failed } = payload 89 | 90 | const res = yield call(Service_addQa, current_group, params) 91 | 92 | if (res) { 93 | message.success(message_success) 94 | } else { 95 | message.error(message_failed) 96 | } 97 | 98 | yield put({ type: 'app/query' }) 99 | 100 | yield put({ 101 | type: 'updateState', 102 | payload: { modal_visible: false } 103 | }) 104 | }, 105 | *delQa ({ payload }, { call, put, select }) { 106 | const { qas, current_id, current_index } = yield select(({ index }) => index) 107 | const { current_group, message_success, message_failed } = payload 108 | 109 | const res = yield call(Service_delQa, current_group, current_id) 110 | 111 | if (res) { 112 | message.success(message_success) 113 | } else { 114 | message.error(message_failed) 115 | } 116 | 117 | const _qas = cloneDeep(qas) 118 | 119 | _qas.splice(current_index, 1) 120 | 121 | yield put({ 122 | type: 'updateState', 123 | payload: { 124 | modal_visible: false, 125 | qas: _qas 126 | } 127 | }) 128 | }, 129 | *putQa ({ payload }, { call, put, select }) { 130 | const { qas, current_id, current_index } = yield select(({ index }) => index) 131 | const { current_group, params, message_success, message_failed } = payload 132 | 133 | const res = yield call(Service_putQa, current_group, current_id, params) 134 | 135 | if (res) { 136 | message.success(message_success) 137 | } else { 138 | message.error(message_failed) 139 | } 140 | 141 | qas[current_index] = Object.assign(qas[current_index], params) 142 | 143 | yield put({ 144 | type: 'updateState', 145 | payload: { 146 | modal_visible: false, 147 | qas: qas 148 | } 149 | }) 150 | }, 151 | *queryQa ({ payload }, { call, put }) { 152 | const { current_group, params: { query, times, rate } } = payload 153 | 154 | const qas = yield call(Service_query, current_group, { query, times, rate }) 155 | 156 | yield put({ 157 | type: 'updateState', 158 | payload: { 159 | modal_visible: false, 160 | qas: qas 161 | } 162 | }) 163 | }, 164 | *rate ({ payload }, { call, put, select }) { 165 | const { qas } = yield select(({ index }) => index) 166 | const { 167 | current_group, 168 | params: { id, rate, index }, 169 | message_success, 170 | message_failed 171 | } = payload 172 | const params = { id, rate } 173 | 174 | const res = yield call(Service_rate, current_group, params) 175 | 176 | if (res) { 177 | message.success(message_success) 178 | } else { 179 | message.error(message_failed) 180 | } 181 | 182 | qas[index].rates.push({ rate, create_at: new Date().valueOf() }) 183 | 184 | yield put({ 185 | type: 'updateState', 186 | payload: { qas: qas } 187 | }) 188 | }, 189 | *clearRateLog ({ payload }, { call, put, select }) { 190 | const { qas } = yield select(({ index }) => index) 191 | const { current_group, id, index, message_success, message_failed } = payload 192 | 193 | const res = yield call(Service_clearRateLog, current_group, id) 194 | 195 | if (res) { 196 | message.success(message_success) 197 | } else { 198 | message.error(message_failed) 199 | } 200 | 201 | qas[index].rates = [] 202 | 203 | yield put({ 204 | type: 'updateState', 205 | payload: { qas: qas } 206 | }) 207 | } 208 | } 209 | }) 210 | -------------------------------------------------------------------------------- /src/pages/index/components/Filter/index.less: -------------------------------------------------------------------------------- 1 | @keyframes show { 2 | from { 3 | max-height: 0; 4 | } 5 | 6 | to { 7 | max-height: 10vh; 8 | } 9 | } 10 | 11 | ._local { 12 | animation: show 0.3s ease; 13 | margin-bottom: 24px; 14 | 15 | :global { 16 | .ant-form-item { 17 | margin-bottom: 0; 18 | } 19 | 20 | .select_filter { 21 | width: 160px; 22 | } 23 | 24 | @media screen and (max-width:420px) { 25 | .select_filter { 26 | width: 30vw; 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/pages/index/components/Filter/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { useIntl } from 'umi' 3 | import { Select, Button, Form } from 'antd' 4 | import { ReloadOutlined, SearchOutlined } from '@ant-design/icons' 5 | import styles from './index.less' 6 | 7 | const { Option } = Select 8 | const { useForm, Item } = Form 9 | 10 | interface IParams { 11 | times?: number 12 | rate?: number 13 | } 14 | 15 | interface IProps { 16 | queryQa: (params: IParams) => void 17 | reset: () => void 18 | } 19 | 20 | const Index = (props: IProps) => { 21 | const { queryQa, reset } = props 22 | const [ form ] = useForm() 23 | const { resetFields, submit } = form 24 | const lang = useIntl() 25 | 26 | const onFinish = ({ times, rate }) => { 27 | if (!times && !rate) return 28 | 29 | queryQa({ times, rate }) 30 | } 31 | 32 | return ( 33 |
39 |
40 |
41 | 42 | 67 | 68 |
69 |
70 | 71 | 87 | 88 |
89 |
90 |
91 |
101 |
102 | ) 103 | } 104 | 105 | export default memo(Index) 106 | -------------------------------------------------------------------------------- /src/pages/index/components/Header/index.less: -------------------------------------------------------------------------------- 1 | ._local { 2 | margin-bottom: 24px; 3 | 4 | :global { 5 | .name { 6 | font-size: 24px; 7 | line-height: 1; 8 | font-weight: bold; 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/pages/index/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | import { useIntl } from 'umi' 3 | import { Button, Tooltip } from 'antd' 4 | import { FileSyncOutlined, FilterOutlined, PlusOutlined } from '@ant-design/icons' 5 | import styles from './index.less' 6 | 7 | interface IProps { 8 | name: string 9 | onAddGroup: () => void 10 | onFilter: () => void 11 | onAddQa: () => void 12 | } 13 | 14 | const Index = (props: IProps) => { 15 | const { name, onAddGroup, onFilter, onAddQa } = props 16 | const lang = useIntl() 17 | 18 | return ( 19 |
20 | {name} 21 |
22 | 23 |
40 |
41 | ) 42 | } 43 | 44 | export default memo(Index) 45 | -------------------------------------------------------------------------------- /src/pages/index/components/Modal/index.less: -------------------------------------------------------------------------------- 1 | ._local { 2 | &.dark { 3 | :global { 4 | .answer { 5 | .ant-input { 6 | background-color: transparent; 7 | } 8 | } 9 | 10 | .ant-tag { 11 | background-color: #333333; 12 | } 13 | } 14 | } 15 | 16 | :global { 17 | .answer { 18 | min-height: 50vh; 19 | max-height: 80vh; 20 | background-color: transparent; 21 | 22 | @media screen and (max-width:840px) { 23 | resize: none; 24 | } 25 | 26 | .ant-input { 27 | background-color: white; 28 | } 29 | } 30 | 31 | .ant-tag { 32 | background-color: white; 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/pages/index/components/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState, useEffect } from 'react' 2 | import { useIntl } from 'umi' 3 | import { Modal, Select, Input, Button, Form, Tag, message } from 'antd' 4 | import { CheckOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons' 5 | import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts' 6 | import { IQas } from '@/db/models/Qas' 7 | import styles from './index.less' 8 | 9 | const { Option } = Select 10 | const { TextArea } = Input 11 | const { confirm } = Modal 12 | const { useForm, Item } = Form 13 | 14 | interface IProps { 15 | theme: string 16 | title: string 17 | groups: Array 18 | current_group: string 19 | current_item: IQas 20 | visible: boolean 21 | modal_type: string 22 | onCancel: () => void 23 | onAddGroup: (name: string) => void 24 | onChangeCurrentGroup: (v: string) => void 25 | onAddQa: (params: IQas) => void 26 | onDelQa: () => void 27 | onPutQa: (params: IQas) => void 28 | } 29 | 30 | const Index = (props: IProps) => { 31 | const { 32 | theme, 33 | title, 34 | groups, 35 | current_group, 36 | current_item, 37 | visible, 38 | modal_type, 39 | onCancel, 40 | onAddGroup, 41 | onChangeCurrentGroup, 42 | onAddQa, 43 | onDelQa, 44 | onPutQa 45 | } = props 46 | const lang = useIntl() 47 | const [ form ] = useForm() 48 | const { validateFields, setFieldsValue } = form 49 | 50 | const [ state_tags, setStateTags ] = useState([]) 51 | const [ state_tags_input, setStateTagsInput ] = useState('') 52 | const [ state_group_input, setStateGroupInput ] = useState('') 53 | const [ state_tags_input_visible, setStateTagsInputVisible ] = useState(false) 54 | 55 | useEffect( 56 | () => { 57 | if (modal_type === 'rate_log') return 58 | 59 | if (current_item.tags) { 60 | setStateTags(current_item.tags) 61 | 62 | setFieldsValue({ 63 | question: current_item.question, 64 | answer: current_item.answer 65 | }) 66 | } 67 | }, 68 | [ current_item ] 69 | ) 70 | 71 | useEffect(() => { 72 | if (modal_type) { 73 | setStateTags(current_item.tags) 74 | } 75 | }, []) 76 | 77 | const delTag = (tag: string) => { 78 | setStateTags(state_tags.filter((item) => item !== tag)) 79 | } 80 | 81 | const onComfirmAddTag = () => { 82 | setStateTagsInput('') 83 | setStateTagsInputVisible(false) 84 | 85 | if (state_tags.length > 4) { 86 | message.warn(lang.formatMessage({ id: 'index.modal.add_qa.tags.count.warn' })) 87 | 88 | return 89 | } 90 | 91 | if (state_tags_input.length > 12) { 92 | message.warn(lang.formatMessage({ id: 'index.modal.add_qa.tags.length.warn' })) 93 | 94 | return 95 | } 96 | 97 | if (state_tags_input && state_tags.indexOf(state_tags_input) === -1) { 98 | setStateTags([ ...state_tags, state_tags_input ]) 99 | } 100 | } 101 | 102 | const props_modal = { 103 | title, 104 | visible, 105 | centered: true, 106 | maskClosable: true, 107 | destroyOnClose: true, 108 | onCancel () { 109 | setStateTags([]) 110 | 111 | if (modal_type === 'edit_qa') { 112 | setFieldsValue({ 113 | question: null, 114 | answer: null 115 | }) 116 | } 117 | 118 | onCancel() 119 | } 120 | } 121 | 122 | const onOk = async () => { 123 | const { question, answer } = await validateFields() 124 | 125 | if (modal_type === 'add_qa') { 126 | onAddQa({ 127 | question, 128 | answer, 129 | tags: state_tags 130 | }) 131 | } 132 | 133 | if (modal_type === 'edit_qa') { 134 | onPutQa({ 135 | question, 136 | answer, 137 | tags: state_tags 138 | }) 139 | } 140 | } 141 | 142 | const onDelete = () => { 143 | confirm({ 144 | centered: true, 145 | title: lang.formatMessage({ id: 'common.confirm' }), 146 | content: lang.formatMessage({ id: 'index.modal.edit_qa.delete.confirm' }), 147 | onOk () { 148 | onDelQa() 149 | } 150 | }) 151 | } 152 | 153 | let _footer: object = {} 154 | 155 | if (modal_type === 'edit_qa') { 156 | _footer = { 157 | footer: ( 158 |
159 | 162 |
163 | 166 | 169 |
170 |
171 | ) 172 | } 173 | } 174 | if (modal_type === 'add_group') { 175 | return ( 176 | 182 | { 193 | setStateGroupInput(e.target.value) 194 | }} 195 | /> 196 |
206 |
207 | )} 208 | defaultValue={current_group} 209 | onChange={onChangeCurrentGroup} 210 | > 211 | {groups.map((item) => ( 212 | 215 | ))} 216 | 217 | 218 | ) 219 | } 220 | 221 | if (modal_type === 'rate_log') { 222 | return ( 223 | 228 | 229 | 230 | new Date(item.create_at).toISOString()} 233 | /> 234 | 235 | 236 | 237 | 238 | 239 | 240 | ) 241 | } 242 | 243 | if (modal_type === 'add_qa' || modal_type === 'edit_qa') { 244 | return ( 245 | 252 |
253 | 264 | 269 | 270 | 281 |