commit 2f6253cfb601c0a429ade1a272f5812a55d363af Author: 陈文彬 Date: Mon Sep 28 20:19:10 2020 +0800 initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..c587bdde9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset=utf-8 +end_of_line=lf +insert_final_newline=false +indent_style=space +indent_size=2 + +[*.yml] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env b/.env new file mode 100644 index 000000000..e69de29bb diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..6727fa1aa --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +VITE_USE_MOCK=true diff --git a/.env.production b/.env.production new file mode 100644 index 000000000..6727fa1aa --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_USE_MOCK=true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..63fe48d15 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,28 @@ + +*.sh +node_modules +lib +*.md +*.scss +*.woff +*.ttf +.vscode +.idea +/dist/ +/mock/ +/public +/docs +.vscode +.local +/bin +/build +/config +Dockerfile +vue.config.js +commit-lint.js +/src/assets/iconfont/ +/types/shims +/src/types/shims +postcss.config.js +stylelint.config.js +commitlint.config.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..bfd145f34 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,62 @@ +module.exports = { + parser: 'vue-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + jsx: true, + }, + }, + + extends: [ + 'plugin:vue/vue3-recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier/@typescript-eslint', + 'plugin:prettier/recommended', + ], + rules: { + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-empty-function': 'off', + 'vue/custom-event-name-casing': 'off', + 'no-use-before-define': 'off', + // 'no-use-before-define': [ + // 'error', + // { + // functions: false, + // classes: true, + // }, + // ], + '@typescript-eslint/no-use-before-define': 'off', + // '@typescript-eslint/no-use-before-define': [ + // 'error', + // { + // functions: false, + // classes: true, + // }, + // ], + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^h$', + varsIgnorePattern: '^h$', + }, + ], + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^h$', + varsIgnorePattern: '^h$', + }, + ], + 'space-before-function-paren': 'off', + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..06eb9c8ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +.DS_Store +dist +*.local +.npmrc +.cache diff --git a/.ls-lint.yml b/.ls-lint.yml new file mode 100644 index 000000000..3c3d9903b --- /dev/null +++ b/.ls-lint.yml @@ -0,0 +1,20 @@ +ls: + src/*: + .js: kebab-case | PascalCase + .vue: PascalCase | regex:^index + .ts: camelCase | PascalCase + .d.ts: kebab-case + .mock.ts: kebab-case + .data.ts: camelCase | kebab-case + .test-d.ts: kebab-case + .less: kebab-case | PascalCase + .spec.ts: camelCase | PascalCase + +ignore: + - node_modules + - .git + - .circleci + - .github + - .vscode + - dist + - .local diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..3d9b47897 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +/dist/* +.local +.output.js +/node_modules/** + +**/*.svg +**/*.sh diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..1a27585d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# 2.0.0 (2020-09-28) + +### Features + +- add 37afeff +- add menu ab58829 +- add menu aeb75e7 +- add split menu 6b2b7bd +- add split menu 2e7cb0b +- add split menu 58fa70e +- add split menu aa87a2c +- auth d36878e +- header be36cc2 +- prettier 3f1db50 + +### Performance Improvements + +- form 2f94a5d +- loading 788fd64 +- lockpage 92d6b7e +- menu ae6ace8 diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..ba2fe3b14 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-present, Vben + +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/build/config/glob/lessModifyVars.ts b/build/config/glob/lessModifyVars.ts new file mode 100644 index 000000000..69b04d226 --- /dev/null +++ b/build/config/glob/lessModifyVars.ts @@ -0,0 +1,26 @@ +/** + * less global variable + */ +const primaryColor = '#018ffb'; +//{ +const modifyVars = { + 'primary-color': primaryColor, // Global dominant color + 'info-color': primaryColor, // Default color + 'success-color': '#55D187', // Success color + 'error-color': '#ED6F6F', // False color + 'warning-color': '#EFBD47', // Warning color + 'link-color': primaryColor, // Link color + 'disabled-color': '#C2C2CC', // Failure color + 'heading-color': '#2C3A61', // Title color + 'text-color': '#2C3A61', // Main text color + 'text-color-secondary ': '#606266', // Subtext color + 'background-color-base': '#F0F2F5', // background color + 'font-size-base': '14px', // Main font size + 'box-shadow-base': '0 2px 8px rgba(0, 0, 0, 0.15)', // Floating shadow + 'border-color-base': '#cececd', // Border color, + 'border-color-split': '#cececd', // Border color, + 'border-radius-base': '2px', // Component/float fillet +}; +//} + +export { modifyVars, primaryColor }; diff --git a/build/config/vite/env.ts b/build/config/vite/env.ts new file mode 100644 index 000000000..313f446c9 --- /dev/null +++ b/build/config/vite/env.ts @@ -0,0 +1,10 @@ +import moment from 'moment'; +// @ts-ignore +import pkg from '../../../package.json'; +export function setupBasicEnv() { + // version + process.env.VITE_VERSION = (pkg as any).version; + // build time + process.env.VITE_APP_BUILD_TIME = moment().format('YYYY-MM-DD HH:mm:ss'); + process.env.VITE_BUILD_SHORT_TIME = moment().format('MMDDHHmmss'); +} diff --git a/build/config/vite/proxy.ts b/build/config/vite/proxy.ts new file mode 100644 index 000000000..1ecd74929 --- /dev/null +++ b/build/config/vite/proxy.ts @@ -0,0 +1,15 @@ +type ProxyItem = [string, string]; + +type ProxyList = ProxyItem[]; + +export function createProxy(list: ProxyList) { + const ret: any = {}; + for (const [prefix, target] of list) { + ret[prefix] = { + target: target, + changeOrigin: true, + rewrite: (path: string) => path.replace(new RegExp(`^${prefix}`), ''), + }; + } + return ret; +} diff --git a/build/gzip/index.ts b/build/gzip/index.ts new file mode 100644 index 000000000..79a81bc7e --- /dev/null +++ b/build/gzip/index.ts @@ -0,0 +1,22 @@ +// Build gzip after packaging +// import { readFile, writeFile } from 'fs'; +import viteConfig from '../../vite.config'; +import { + // basename, + join, +} from 'path'; +// import { promisify } from 'util'; +// import { gzip, ZlibOptions } from 'zlib'; +import { readAllFile } from '../utils'; + +// const readFilePromise = promisify(readFile); +// const writeFilePromise = promisify(writeFile); + +// function createGzip() {} + +const FILE_REG = /\.(js|mjs|json|css|html)$/; + +const OUT_DIR = viteConfig.outDir || 'dist'; + +// TODO 待开发 +const files = readAllFile(join(process.cwd(), OUT_DIR), FILE_REG); diff --git a/build/gzip/types.ts b/build/gzip/types.ts new file mode 100644 index 000000000..2250bcc93 --- /dev/null +++ b/build/gzip/types.ts @@ -0,0 +1,56 @@ +import type { ZlibOptions } from 'zlib'; + +export type StringMappingOption = (originalString: string) => string; +export type CustomCompressionOption = ( + content: string | Buffer +) => string | Buffer | Promise; + +export interface GzipPluginOptions { + /** + * Control which of the output files to compress + * + * Defaults to `/\.(js|mjs|json|css|html)$/` + */ + filter?: RegExp | ((fileName: string) => boolean); + + /** + * GZIP compression options, see https://nodejs.org/api/zlib.html#zlib_class_options + */ + gzipOptions?: ZlibOptions; + + /** + * Specified the minimum size in Bytes for a file to get compressed. + * Files that are smaller than this threshold will not be compressed. + * This does not apply to the files specified through `additionalFiles`! + */ + minSize?: number; + + /** + * This option allows you to compress additional files outside of the main rollup bundling process. + * The processing is delayed to make sure the files are written on disk; the delay is controlled + * through `additionalFilesDelay`. + */ + additionalFiles?: string[]; + + /** + * This options sets a delay (ms) before the plugin compresses the files specified through `additionalFiles`. + * Increase this value if your artifacts take a long time to generate. + * + * Defaults to `2000` + */ + additionalFilesDelay?: number; + + /** + * Set a custom compression algorithm. The function can either return the compressed contents synchronously, + * or otherwise return a promise for asynchronous processing. + */ + customCompression?: CustomCompressionOption; + + /** + * Set a custom file name convention for the compressed files. Can be a suffix string or a function + * returning the file name. + * + * Defaults to `.gz` + */ + fileName?: string | StringMappingOption; +} diff --git a/build/script/changelog.ts b/build/script/changelog.ts new file mode 100644 index 000000000..d34011c2e --- /dev/null +++ b/build/script/changelog.ts @@ -0,0 +1,39 @@ +// #!/usr/bin/env node + +import { sh } from 'tasksfile'; +import chalk from 'chalk'; + +const createChangeLog = async () => { + try { + let cmd = `conventional-changelog -p angular -i CHANGELOG.md -s -r 0 `; + // let cmd = `conventional-changelog -p angular -i CHANGELOG.md -s -r 0 `; + // if (shell.which('git')) { + // cmd += '&& git add CHANGELOG.md'; + // } + await sh(cmd, { + async: true, + nopipe: true, + }); + + await sh('prettier --write **/CHANGELOG.md ', { + async: true, + nopipe: true, + }); + console.log( + chalk.blue.bold('**************** ') + + chalk.green.bold('CHANGE_LOG generated successfully!') + + chalk.blue.bold(' ****************') + ); + } catch (error) { + console.log( + chalk.blue.red('**************** ') + + chalk.green.red('CHANGE_LOG generated error\n' + error) + + chalk.blue.red(' ****************') + ); + process.exit(1); + } +}; +createChangeLog(); +module.exports = { + createChangeLog, +}; diff --git a/build/script/postinstall.ts b/build/script/postinstall.ts new file mode 100644 index 000000000..242489f33 --- /dev/null +++ b/build/script/postinstall.ts @@ -0,0 +1,10 @@ +import { exec, which } from 'shelljs'; + +function ignoreCaseGit() { + try { + if (which('git')) { + exec('git config core.ignorecase false '); + } + } catch (error) {} +} +ignoreCaseGit(); diff --git a/build/script/preserve.ts b/build/script/preserve.ts new file mode 100644 index 000000000..4f19e503a --- /dev/null +++ b/build/script/preserve.ts @@ -0,0 +1,67 @@ +// 是否需要更新依赖,防止package.json更新了依赖,其他人获取代码后没有install + +import path from 'path'; +import fs from 'fs-extra'; +import { isEqual } from 'lodash'; +import chalk from 'chalk'; +import { sh } from 'tasksfile'; + +const resolve = (dir: string) => { + return path.resolve(process.cwd(), dir); +}; + +let NEED_INSTALL = false; + +fs.mkdirp(resolve('build/.cache')); +function checkPkgUpdate() { + const pkg = require('../../package.json'); + const { dependencies, devDependencies } = pkg; + const depsFile = resolve('build/.cache/deps.json'); + if (!fs.pathExistsSync(depsFile)) { + NEED_INSTALL = true; + return; + } + const depsJson = require('../.cache/deps.json'); + + if (!isEqual(depsJson, { dependencies, devDependencies })) { + NEED_INSTALL = true; + } +} +checkPkgUpdate(); + +(async () => { + if (NEED_INSTALL) { + console.log( + chalk.blue.bold('**************** ') + + chalk.red.bold('检测到依赖变化,正在安装依赖(Tip: 项目首次运行也会执行)!') + + chalk.blue.bold(' ****************') + ); + try { + // 从代码执行貌似不会自动读取.npmrc 所以手动加上源地址 + // await run('yarn install --registry=https://registry.npm.taobao.org ', { + await sh('yarn install ', { + async: true, + nopipe: true, + }); + console.log( + chalk.blue.bold('**************** ') + + chalk.green.bold('依赖安装成功,正在运行!') + + chalk.blue.bold(' ****************') + ); + + const pkg = require('../../package.json'); + const { dependencies, devDependencies } = pkg; + const depsFile = resolve('build/.cache/deps.json'); + const deps = { dependencies, devDependencies }; + if (!fs.pathExistsSync(depsFile)) { + fs.writeFileSync(depsFile, JSON.stringify(deps)); + } else { + const depsFile = resolve('build/.cache/deps.json'); + const depsJson = require('../.cache/deps.json'); + if (!isEqual(depsJson, deps)) { + fs.writeFileSync(depsFile, JSON.stringify(deps)); + } + } + } catch (error) {} + } +})(); diff --git a/build/script/preview.ts b/build/script/preview.ts new file mode 100644 index 000000000..3bc70fb2f --- /dev/null +++ b/build/script/preview.ts @@ -0,0 +1,70 @@ +import chalk from 'chalk'; +import Koa from 'koa'; +import inquirer from 'inquirer'; +import { sh } from 'tasksfile'; +import staticServer from 'koa-static'; +import portfinder from 'portfinder'; +import { resolve } from 'path'; +import viteConfig from '../../vite.config'; +import { getIPAddress } from '../utils'; + +const BUILD = 1; +const NO_BUILD = 2; + +// 启动服务器 +const startApp = () => { + const port = 9680; + portfinder.basePort = port; + const app = new Koa(); + // const connect = require('connect'); + // const serveStatic = require('serve-static'); + // const app = connect(); + + app.use(staticServer(resolve(process.cwd(), viteConfig.outDir || 'dist'))); + + portfinder.getPort(async (err, port) => { + if (err) { + throw err; + } else { + // const publicPath = process.env.BASE_URL; + app.listen(port, function () { + const empty = ' '; + const common = `The preview program is already running: + - LOCAL: http://localhost:${port}/ + - NETWORK: http://${getIPAddress()}:${port}/ + `; + console.log(chalk.cyan('\n' + empty + common)); + }); + } + }); +}; + +const preview = async () => { + const prompt = inquirer.prompt({ + type: 'list', + message: 'Please select a preview method', + name: 'type', + choices: [ + { + name: 'Preview after packaging', + value: BUILD, + }, + { + name: `No packaging, preview directly (need to have dist file after packaging)`, + value: NO_BUILD, + }, + ], + }); + const { type } = await prompt; + if (type === BUILD) { + await sh('npm run build', { + async: true, + nopipe: true, + }); + } + startApp(); +}; + +(() => { + preview(); +})(); diff --git a/build/tsconfig.json b/build/tsconfig.json new file mode 100644 index 000000000..5f9bb94af --- /dev/null +++ b/build/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "baseUrl": ".", + "esModuleInterop": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "experimentalDecorators": true, + "lib": ["dom", "esnext"], + "incremental": true, + "skipLibCheck": true + } +} diff --git a/build/utils.ts b/build/utils.ts new file mode 100644 index 000000000..eaa0cd04d --- /dev/null +++ b/build/utils.ts @@ -0,0 +1,87 @@ +import fs from 'fs'; +import { networkInterfaces } from 'os'; +import dotenv from 'dotenv'; + +export const isFunction = (arg: unknown): arg is (...args: any[]) => any => + typeof arg === 'function'; +export const isRegExp = (arg: unknown): arg is RegExp => + Object.prototype.toString.call(arg) === '[object RegExp]'; + +/* + * Read all files in the specified folder, filter through regular rules, and return file path array + * @param root Specify the folder path + * [@param] reg Regular expression for filtering files, optional parameters + * Note: It can also be deformed to check whether the file path conforms to regular rules. The path can be a folder or a file. The path that does not exist is also fault-tolerant. + */ +export function readAllFile(root: string, reg: RegExp) { + let resultArr: string[] = []; + try { + if (fs.existsSync(root)) { + const stat = fs.lstatSync(root); + if (stat.isDirectory()) { + // dir + const files = fs.readdirSync(root); + files.forEach(function (file) { + const t = readAllFile(root + '/' + file, reg); + resultArr = resultArr.concat(t); + }); + } else { + if (reg !== undefined) { + if (isFunction(reg.test) && reg.test(root)) { + resultArr.push(root); + } + } else { + resultArr.push(root); + } + } + } + } catch (error) {} + + return resultArr; +} + +export function getIPAddress() { + let interfaces = networkInterfaces(); + for (let devName in interfaces) { + let iFace = interfaces[devName]; + if (!iFace) return; + for (let i = 0; i < iFace.length; i++) { + let alias = iFace[i]; + if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) { + return alias.address; + } + } + } + + return ''; +} + +export function isDevFn(): boolean { + return process.env.NODE_ENV === 'development'; +} + +export function isProdFn(): boolean { + return process.env.NODE_ENV === 'production'; +} + +export function isReportMode(): boolean { + return process.env.REPORT === 'true'; +} + +export function loadEnv() { + const env = process.env.NODE_ENV; + const ret: any = {}; + const envList = [`.env.${env}.local`, `.env.${env}`, '.env.local', '.env', ,]; + envList.forEach((e) => { + dotenv.config({ + path: e, + }); + }); + + for (const envName of Object.keys(process.env)) { + const realName = (process.env as any)[envName].replace(/\\n/g, '\n'); + ret[envName] = realName; + process.env[envName] = realName; + } + return ret; +} diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 000000000..2aeada74d --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,55 @@ +module.exports = { + ignores: [(commit) => commit.includes('init')], + extends: ['@commitlint/config-conventional'], + parserPreset: { + parserOpts: { + headerPattern: /^(\w*|[\u4e00-\u9fa5]*)(?:[\(\(](.*)[\)\)])?[\:\:] (.*)/, + headerCorrespondence: ['type', 'scope', 'subject'], + referenceActions: [ + 'close', + 'closes', + 'closed', + 'fix', + 'fixes', + 'fixed', + 'resolve', + 'resolves', + 'resolved', + ], + issuePrefixes: ['#'], + noteKeywords: ['BREAKING CHANGE', '不兼容变更'], + fieldPattern: /^-(.*?)-$/, + revertPattern: /^Revert\s"([\s\S]*)"\s*This reverts commit (\w*)\./, + revertCorrespondence: ['header', 'hash'], + warn() {}, + mergePattern: null, + mergeCorrespondence: null, + }, + }, + rules: { + 'body-leading-blank': [2, 'always'], + 'footer-leading-blank': [1, 'always'], + 'header-max-length': [2, 'always', 108], + 'subject-empty': [2, 'never'], + 'type-empty': [2, 'never'], + 'type-enum': [ + 2, + 'always', + [ + 'feat', + 'fix', + 'perf', + 'style', + 'docs', + 'test', + 'refactor', + 'build', + 'ci', + 'chore', + 'revert', + 'wip', + 'workflow', + ], + ], + }, +}; diff --git a/index.html b/index.html new file mode 100644 index 000000000..57b3a3220 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vue Vben admin 2.0 + + +
+ + + diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 000000000..2857b0c34 --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,8 @@ +module.exports = { + '*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'], + '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': ['prettier --write--parser json'], + 'package.json': ['prettier --write'], + '*.vue': ['prettier --write', 'stylelint --fix', 'git add .'], + '*.{scss,less,styl,css,html}': ['stylelint --fix', 'prettier --write', 'git add .'], + '*.md': ['prettier --write'], +}; diff --git a/mock/_createProductionServer.ts b/mock/_createProductionServer.ts new file mode 100644 index 000000000..a81cebf5a --- /dev/null +++ b/mock/_createProductionServer.ts @@ -0,0 +1,7 @@ +import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'; +import userMock from './sys/user'; +import menuMock from './sys/menu'; + +export function setupProdMockServer() { + createProdMockServer([...userMock, ...menuMock]); +} diff --git a/mock/_util.ts b/mock/_util.ts new file mode 100644 index 000000000..6e4dc0ef9 --- /dev/null +++ b/mock/_util.ts @@ -0,0 +1,38 @@ +// Interface data format used to return a unified format + +export function resultSuccess(result: T, { message = 'ok' } = {}) { + return { + code: 0, + result, + message, + type: 'success', + }; +} + +export function resultPageSuccess(items: T[], total: number, { message = 'ok' } = {}) { + return { + code: 0, + result: { + items, + total, + }, + message, + type: 'success', + }; +} + +export function resultError(message = 'Request failed', { code = -1, result = null } = {}) { + return { + code, + result, + message, + type: 'error', + }; +} + +export function pagination(pageNo: number, pageSize: number, array: T[]): T[] { + let offset = (pageNo - 1) * pageSize; + return offset + pageSize >= array.length + ? array.slice(offset, array.length) + : array.slice(offset, offset + pageSize); +} diff --git a/mock/sys/menu.ts b/mock/sys/menu.ts new file mode 100644 index 000000000..2f9623258 --- /dev/null +++ b/mock/sys/menu.ts @@ -0,0 +1,132 @@ +import { resultSuccess } from '../_util'; +import { MockMethod } from 'vite-plugin-mock'; + +const dashboardRoute = { + layout: { + path: '/dashboard', + name: 'Dashboard', + component: 'PAGE_LAYOUT', + redirect: '/dashboard/welcome', + meta: { + icon: 'ant-design:home-outlined', + title: 'Dashboard', + }, + }, + routes: [ + { + path: '/welcome', + name: 'Welcome', + component: '/dashboard/welcome/index.vue', + meta: { + title: '欢迎页', + affix: true, + }, + }, + ], +}; + +const frontRoute = { + path: '/front', + name: 'PermissionFrontDemo', + meta: { + title: '基于前端权限', + }, + children: [ + { + path: 'page', + component: '/demo/permission/front/index.vue', + meta: { + title: '页面权限', + }, + }, + { + path: 'btn', + component: '/demo/permission/front/Btn.vue', + meta: { + title: '按钮权限', + }, + }, + { + path: 'auth-pageA', + component: '/demo/permission/front/AuthPageA.vue', + meta: { + title: '权限测试页A', + }, + }, + { + path: 'auth-pageB', + component: '/demo/permission/front/AuthPageB.vue', + meta: { + title: '权限测试页B', + }, + }, + ], +}; +const backRoute = { + path: '/back', + name: 'PermissionBackDemo', + meta: { + title: '基于后台权限', + }, + children: [ + { + path: 'page', + component: 'demo/permission/back/index.vue', + meta: { + title: '页面权限', + }, + }, + { + path: 'btn', + component: '/demo/permission/back/Btn.vue', + meta: { + title: '按钮权限', + }, + }, + ], +}; +const authRoute = { + layout: { + path: '/permission', + name: 'Permission', + component: 'PAGE_LAYOUT', + redirect: '/permission/front/page', + meta: { + icon: 'ant-design:home-outlined', + title: '权限管理', + }, + }, + + routes: [frontRoute, backRoute], +}; + +const authRoute1 = { + layout: { + path: '/permission', + name: 'Permission', + component: 'PAGE_LAYOUT', + redirect: '/permission/front/page', + meta: { + icon: 'ant-design:home-outlined', + title: '权限管理', + }, + }, + + routes: [backRoute], +}; +export default [ + { + url: '/api/getMenuListById', + timeout: 1000, + method: 'get', + response: ({ query }) => { + const { id } = query; + if (!id || id === '1') { + return resultSuccess([dashboardRoute, authRoute]); + } + if (id === '2') { + return resultSuccess([dashboardRoute, authRoute1]); + } + }, + }, +] as MockMethod[]; diff --git a/mock/sys/user.ts b/mock/sys/user.ts new file mode 100644 index 000000000..f89c0f135 --- /dev/null +++ b/mock/sys/user.ts @@ -0,0 +1,90 @@ +import { MockMethod } from 'vite-plugin-mock'; +import { resultError, resultSuccess } from '../_util'; + +function createFakeUserList() { + return [ + { + userId: '1', + username: 'vben', + realName: 'Vben', + desc: 'manager', + password: '123456', + token: 'fakeToken1', + role: { + roleName: 'Super Admin', + value: 'super', + }, + }, + { + userId: '2', + username: 'test', + password: '123456', + realName: 'test user', + desc: 'tester', + token: 'fakeToken2', + role: { + roleName: 'Tester', + value: 'test', + }, + }, + ]; +} + +const fakeCodeList: any = { + '1': ['1000', '3000', '5000'], + + '2': ['2000', '4000', '6000'], +}; +export default [ + // mock user login + { + url: '/api/login', + timeout: 1000, + method: 'post', + response: ({ body }) => { + const { username, password } = body; + const checkUser = createFakeUserList().find( + (item) => item.username === username && password === item.password + ); + if (!checkUser) { + return resultError('Incorrect account or password!'); + } + const { userId, username: _username, token, realName, desc, role } = checkUser; + return resultSuccess({ + role, + userId, + username: _username, + token, + realName, + desc, + }); + }, + }, + { + url: '/api/getUserInfoById', + timeout: 200, + method: 'get', + response: ({ query }) => { + const { userId } = query; + const checkUser = createFakeUserList().find((item) => item.userId === userId); + if (!checkUser) { + return resultError('The corresponding user information was not obtained!'); + } + return resultSuccess(checkUser); + }, + }, + { + url: '/api/getPermCodeByUserId', + timeout: 200, + method: 'get', + response: ({ query }) => { + const { userId } = query; + if (!userId) { + return resultError('userId is not null!'); + } + const codeList = fakeCodeList[userId]; + + return resultSuccess(codeList); + }, + }, +] as MockMethod[]; diff --git a/package.json b/package.json new file mode 100644 index 000000000..6034b4db1 --- /dev/null +++ b/package.json @@ -0,0 +1,104 @@ +{ + "name": "vben-admin-2.0", + "version": "2.0.0-beta.1", + "scripts": { + "bootstrap": "yarn install", + "serve": "ts-node --project ./build/tsconfig.json ./build/script/preserve && cross-env NODE_ENV=development vite", + "build": "cross-env NODE_ENV=production vite build ", + "report": "cross-env REPORT=true yarn build ", + "build:no-cache": "yarn clean:cache && yarn build", + "preview": "ts-node --project ./build/tsconfig.json ./build/script/preview", + "log": "ts-node --project ./build/tsconfig.json ./build/script/changelog", + "gen:gz": "ts-node --project build/tsconfig.build.json ./build/gzip/index.ts ", + "clean:cache": "npx rimraf node_modules/.cache/ && npx rimraf node_modules/.vite_opt_cache", + "clean:lib": "npx rimraf node_modules", + "ls-lint": "npx ls-lint", + "lint:eslint": "eslint --fix --ext \"src/**/*.{vue,less,css,scss}\"", + "lint:prettier": "prettier --write --loglevel warn \"src/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"", + "lint:stylelint": "stylelint --fix \"**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/", + "reinstall": "npx rimraf node_modules && npx rimraf yarn.lock && npx rimraf package.lock.json && yarn run bootstrap", + "postinstall": "ts-node --project ./build/tsconfig.json ./build/script/postinstall" + }, + "dependencies": { + "@iconify/iconify": "^2.0.0-rc.1", + "ant-design-vue": "^2.0.0-beta.10", + "axios": "^0.20.0", + "lodash-es": "^4.17.15", + "mockjs": "^1.1.0", + "nprogress": "^0.2.0", + "path-to-regexp": "^6.1.0", + "qrcode": "^1.4.4", + "vue": "^3.0.0", + "vue-i18n": "^9.0.0-beta.3", + "vue-router": "^4.0.0-beta.12", + "vuex": "^4.0.0-beta.4", + "vuex-module-decorators": "^1.0.1", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "@commitlint/cli": "^11.0.0", + "@commitlint/config-conventional": "^11.0.0", + "@iconify/json": "^1.1.233", + "@ls-lint/ls-lint": "^1.9.2", + "@purge-icons/generated": "^0.4.1", + "@types/fs-extra": "^9.0.1", + "@types/inquirer": "^7.3.1", + "@types/koa-static": "^4.0.1", + "@types/lodash-es": "^4.17.3", + "@types/mockjs": "^1.0.3", + "@types/nprogress": "^0.2.0", + "@types/qrcode": "^1.3.5", + "@types/rollup-plugin-visualizer": "^2.6.0", + "@types/shelljs": "^0.8.8", + "@types/zxcvbn": "^4.4.0", + "@typescript-eslint/eslint-plugin": "^4.2.0", + "@typescript-eslint/parser": "^4.2.0", + "@vue/compiler-sfc": "^3.0.0", + "autoprefixer": "^9.8.6", + "babel-plugin-import": "^1.13.0", + "commitizen": "^4.2.1", + "conventional-changelog-cli": "^2.1.0", + "cross-env": "^7.0.2", + "dotenv": "^8.2.0", + "eslint": "^7.10.0", + "eslint-config-prettier": "^6.12.0", + "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-vue": "^7.0.0-beta.4", + "fs-extra": "^9.0.1", + "husky": "^4.3.0", + "inquirer": "^7.3.3", + "koa-static": "^5.0.0", + "less": "^3.12.2", + "lint-staged": "^10.4.0", + "ora": "^5.1.0", + "portfinder": "^1.0.28", + "postcss-import": "^12.0.1", + "prettier": "^2.1.2", + "rimraf": "^3.0.2", + "rollup-plugin-analyzer": "^3.3.0", + "rollup-plugin-visualizer": "^4.1.1", + "shelljs": "^0.8.4", + "stylelint": "^13.7.2", + "stylelint-config-prettier": "^8.0.2", + "stylelint-config-standard": "^20.0.0", + "stylelint-order": "^4.1.0", + "tailwindcss": "^1.8.10", + "tasksfile": "^5.1.1", + "ts-node": "^9.0.0", + "typescript": "^4.0.3", + "vite": "^1.0.0-rc.4", + "vite-jsx": "^1.0.5", + "vite-plugin-mock": "^1.0.2", + "vite-plugin-purge-icons": "^0.4.1", + "vue-eslint-parser": "^7.1.0" + }, + "husky": { + "hooks": { + "pre-commit": "ls-lint && lint-staged", + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" + } + }, + "engines": { + "node": ">=12.0.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100755 index 000000000..c0544b489 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,4 @@ +const path = require('path'); +module.exports = { + plugins: [require('tailwindcss'), require('autoprefixer'), require('postcss-import')], +}; diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 000000000..e0f42b9d9 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,28 @@ +module.exports = { + printWidth: 100, + tabWidth: 2, + useTabs: false, + semi: true, + vueIndentScriptAndStyle: true, + singleQuote: true, + quoteProps: 'as-needed', + bracketSpacing: true, + trailingComma: 'es5', + jsxBracketSameLine: false, + jsxSingleQuote: false, + arrowParens: 'always', + insertPragma: false, + requirePragma: false, + proseWrap: 'never', + htmlWhitespaceSensitivity: 'strict', + endOfLine: 'lf', + rangeStart: 0, + overrides: [ + { + files: '*.md', + options: { + tabWidth: 2, + }, + }, + ], +}; diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..d92e0b8c4 Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 000000000..617aa4c16 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/api/sys/menu.ts b/src/api/sys/menu.ts new file mode 100644 index 000000000..cd9509abf --- /dev/null +++ b/src/api/sys/menu.ts @@ -0,0 +1,18 @@ +import { defHttp } from '/@/utils/http/axios'; + +import { getMenuListByIdParams, getMenuListByIdParamsResultModel } from './model/menuModel'; + +enum Api { + GetMenuListById = '/getMenuListById', +} + +/** + * @description: 根据id获取用户菜单 + */ +export function getMenuListById(params: getMenuListByIdParams) { + return defHttp.request({ + url: Api.GetMenuListById, + method: 'GET', + params, + }); +} diff --git a/src/api/sys/model/menuModel.ts b/src/api/sys/model/menuModel.ts new file mode 100644 index 000000000..c6c8cdd8f --- /dev/null +++ b/src/api/sys/model/menuModel.ts @@ -0,0 +1,23 @@ +import { RouteMeta } from '/@/router/types'; +export interface RouteItem { + path: string; + component: any; + meta: RouteMeta; + name?: string; + alias?: string | string[]; + redirect?: string; + caseSensitive?: boolean; + children?: RouteItem[]; +} + +/** + * @description: 获取菜单接口 + */ +export interface getMenuListByIdParams { + id: number | string; +} + +/** + * @description: 获取菜单返回值 + */ +export type getMenuListByIdParamsResultModel = RouteItem[]; diff --git a/src/api/sys/model/userModel.ts b/src/api/sys/model/userModel.ts new file mode 100644 index 000000000..797224274 --- /dev/null +++ b/src/api/sys/model/userModel.ts @@ -0,0 +1,43 @@ +/** + * @description: Login interface parameters + */ +export interface LoginParams { + username: string; + password: string; +} + +/** + * @description: Get user information + */ +export interface GetUserInfoByUserIdParams { + userId: string | number; +} + +export interface RoleInfo { + roleName: string; + value: string; +} + +/** + * @description: Login interface return value + */ +export interface LoginResultModel { + userId: string | number; + token: string; + role: RoleInfo; +} + +/** + * @description: Get user information return value + */ +export interface GetUserInfoByUserIdModel { + role: RoleInfo; + // 用户id + userId: string | number; + // 用户名 + username: string; + // 真实名字 + realName: string; + // 介绍 + desc?: string; +} diff --git a/src/api/sys/user.ts b/src/api/sys/user.ts new file mode 100644 index 000000000..acd996e1c --- /dev/null +++ b/src/api/sys/user.ts @@ -0,0 +1,48 @@ +import { defHttp } from '/@/utils/http/axios'; +import { + LoginParams, + LoginResultModel, + GetUserInfoByUserIdParams, + GetUserInfoByUserIdModel, +} from './model/userModel'; + +enum Api { + Login = '/login', + GetUserInfoById = '/getUserInfoById', + GetPermCodeByUserId = '/getPermCodeByUserId', +} + +/** + * @description: user login api + */ +export function loginApi(params: LoginParams) { + return defHttp.request( + { + url: Api.Login, + method: 'POST', + params, + }, + { + errorMessageMode: 'modal', + } + ); +} + +/** + * @description: getUserInfoById + */ +export function getUserInfoById(params: GetUserInfoByUserIdParams) { + return defHttp.request({ + url: Api.GetUserInfoById, + method: 'GET', + params, + }); +} + +export function getPermCodeByUserId(params: GetUserInfoByUserIdParams) { + return defHttp.request({ + url: Api.GetPermCodeByUserId, + method: 'GET', + params, + }); +} diff --git a/src/assets/images/dashboard/wokb/approve.png b/src/assets/images/dashboard/wokb/approve.png new file mode 100644 index 000000000..6155fb2a0 Binary files /dev/null and b/src/assets/images/dashboard/wokb/approve.png differ diff --git a/src/assets/images/dashboard/wokb/attendance.png b/src/assets/images/dashboard/wokb/attendance.png new file mode 100644 index 000000000..185b88845 Binary files /dev/null and b/src/assets/images/dashboard/wokb/attendance.png differ diff --git a/src/assets/images/dashboard/wokb/datashow1.png b/src/assets/images/dashboard/wokb/datashow1.png new file mode 100644 index 000000000..9af6b87dd Binary files /dev/null and b/src/assets/images/dashboard/wokb/datashow1.png differ diff --git a/src/assets/images/dashboard/wokb/datashow2.png b/src/assets/images/dashboard/wokb/datashow2.png new file mode 100644 index 000000000..d89f105d4 Binary files /dev/null and b/src/assets/images/dashboard/wokb/datashow2.png differ diff --git a/src/assets/images/dashboard/wokb/datashow3.png b/src/assets/images/dashboard/wokb/datashow3.png new file mode 100644 index 000000000..1c3349397 Binary files /dev/null and b/src/assets/images/dashboard/wokb/datashow3.png differ diff --git a/src/assets/images/dashboard/wokb/datashow4.png b/src/assets/images/dashboard/wokb/datashow4.png new file mode 100644 index 000000000..4035867b9 Binary files /dev/null and b/src/assets/images/dashboard/wokb/datashow4.png differ diff --git a/src/assets/images/dashboard/wokb/leave.png b/src/assets/images/dashboard/wokb/leave.png new file mode 100644 index 000000000..a96fd5458 Binary files /dev/null and b/src/assets/images/dashboard/wokb/leave.png differ diff --git a/src/assets/images/dashboard/wokb/meal.png b/src/assets/images/dashboard/wokb/meal.png new file mode 100644 index 000000000..6374fda87 Binary files /dev/null and b/src/assets/images/dashboard/wokb/meal.png differ diff --git a/src/assets/images/dashboard/wokb/overtime.png b/src/assets/images/dashboard/wokb/overtime.png new file mode 100644 index 000000000..0796a1f76 Binary files /dev/null and b/src/assets/images/dashboard/wokb/overtime.png differ diff --git a/src/assets/images/dashboard/wokb/performance.png b/src/assets/images/dashboard/wokb/performance.png new file mode 100644 index 000000000..de85be79e Binary files /dev/null and b/src/assets/images/dashboard/wokb/performance.png differ diff --git a/src/assets/images/dashboard/wokb/stamp.png b/src/assets/images/dashboard/wokb/stamp.png new file mode 100644 index 000000000..c7a733417 Binary files /dev/null and b/src/assets/images/dashboard/wokb/stamp.png differ diff --git a/src/assets/images/dashboard/wokb/travel.png b/src/assets/images/dashboard/wokb/travel.png new file mode 100644 index 000000000..dab9998c6 Binary files /dev/null and b/src/assets/images/dashboard/wokb/travel.png differ diff --git a/src/assets/images/dashboard/wokb/wokb.png b/src/assets/images/dashboard/wokb/wokb.png new file mode 100644 index 000000000..75c2a591c Binary files /dev/null and b/src/assets/images/dashboard/wokb/wokb.png differ diff --git a/src/assets/images/exception/404.png b/src/assets/images/exception/404.png new file mode 100644 index 000000000..8d8b4c3d1 Binary files /dev/null and b/src/assets/images/exception/404.png differ diff --git a/src/assets/images/exception/500.png b/src/assets/images/exception/500.png new file mode 100644 index 000000000..c62ad9780 Binary files /dev/null and b/src/assets/images/exception/500.png differ diff --git a/src/assets/images/exception/net-work.png b/src/assets/images/exception/net-work.png new file mode 100644 index 000000000..0b37161a1 Binary files /dev/null and b/src/assets/images/exception/net-work.png differ diff --git a/src/assets/images/header.jpg b/src/assets/images/header.jpg new file mode 100644 index 000000000..6b16a68bb Binary files /dev/null and b/src/assets/images/header.jpg differ diff --git a/src/assets/images/layout/menu-mix.svg b/src/assets/images/layout/menu-mix.svg new file mode 100644 index 000000000..ec6c58b0a --- /dev/null +++ b/src/assets/images/layout/menu-mix.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/layout/menu-sidebar.svg b/src/assets/images/layout/menu-sidebar.svg new file mode 100644 index 000000000..04904a13f --- /dev/null +++ b/src/assets/images/layout/menu-sidebar.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/layout/menu-top.svg b/src/assets/images/layout/menu-top.svg new file mode 100644 index 000000000..c36ea4980 --- /dev/null +++ b/src/assets/images/layout/menu-top.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/loading.svg b/src/assets/images/loading.svg new file mode 100644 index 000000000..b85b89286 --- /dev/null +++ b/src/assets/images/loading.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/lock-page.jpg b/src/assets/images/lock-page.jpg new file mode 100644 index 000000000..593e9d177 Binary files /dev/null and b/src/assets/images/lock-page.jpg differ diff --git a/src/assets/images/lock-page.png b/src/assets/images/lock-page.png new file mode 100644 index 000000000..645c7e916 Binary files /dev/null and b/src/assets/images/lock-page.png differ diff --git a/src/assets/images/login/login-bg.png b/src/assets/images/login/login-bg.png new file mode 100644 index 000000000..b773a2452 Binary files /dev/null and b/src/assets/images/login/login-bg.png differ diff --git a/src/assets/images/login/login-in.png b/src/assets/images/login/login-in.png new file mode 100644 index 000000000..3a45b722e Binary files /dev/null and b/src/assets/images/login/login-in.png differ diff --git a/src/assets/images/logo.png b/src/assets/images/logo.png new file mode 100644 index 000000000..cd4c33d8b Binary files /dev/null and b/src/assets/images/logo.png differ diff --git a/src/assets/images/no-data.png b/src/assets/images/no-data.png new file mode 100644 index 000000000..39545cabb Binary files /dev/null and b/src/assets/images/no-data.png differ diff --git a/src/assets/images/page_null.png b/src/assets/images/page_null.png new file mode 100644 index 000000000..5e7cb211a Binary files /dev/null and b/src/assets/images/page_null.png differ diff --git a/src/assets/images/qq.jpeg b/src/assets/images/qq.jpeg new file mode 100644 index 000000000..84b9cc58f Binary files /dev/null and b/src/assets/images/qq.jpeg differ diff --git a/src/assets/images/sidebar/dark-mini.png b/src/assets/images/sidebar/dark-mini.png new file mode 100644 index 000000000..be98bfde4 Binary files /dev/null and b/src/assets/images/sidebar/dark-mini.png differ diff --git a/src/assets/images/sidebar/dark.png b/src/assets/images/sidebar/dark.png new file mode 100644 index 000000000..c355797a0 Binary files /dev/null and b/src/assets/images/sidebar/dark.png differ diff --git a/src/assets/images/sidebar/light-mini.png b/src/assets/images/sidebar/light-mini.png new file mode 100644 index 000000000..d6dd4fbc2 Binary files /dev/null and b/src/assets/images/sidebar/light-mini.png differ diff --git a/src/assets/images/sidebar/light.png b/src/assets/images/sidebar/light.png new file mode 100644 index 000000000..7a4ed55cc Binary files /dev/null and b/src/assets/images/sidebar/light.png differ diff --git a/src/assets/svg/preview/p-rotate.svg b/src/assets/svg/preview/p-rotate.svg new file mode 100644 index 000000000..fc6cc80e5 --- /dev/null +++ b/src/assets/svg/preview/p-rotate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/preview/resume.svg b/src/assets/svg/preview/resume.svg new file mode 100644 index 000000000..946761141 --- /dev/null +++ b/src/assets/svg/preview/resume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/preview/scale.svg b/src/assets/svg/preview/scale.svg new file mode 100644 index 000000000..4c1dea735 --- /dev/null +++ b/src/assets/svg/preview/scale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/preview/unrotate.svg b/src/assets/svg/preview/unrotate.svg new file mode 100644 index 000000000..82d28d132 --- /dev/null +++ b/src/assets/svg/preview/unrotate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svg/preview/unscale.svg b/src/assets/svg/preview/unscale.svg new file mode 100644 index 000000000..6aa8d48a9 --- /dev/null +++ b/src/assets/svg/preview/unscale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Authority/index.tsx b/src/components/Authority/index.tsx new file mode 100644 index 000000000..a93f71c28 --- /dev/null +++ b/src/components/Authority/index.tsx @@ -0,0 +1,59 @@ +import { defineComponent, PropType, computed, unref } from 'vue'; + +import { PermissionModeEnum } from '/@/enums/appEnum'; +import { RoleEnum } from '/@/enums/roleEnum'; +import { usePermission } from '/@/hooks/web/usePermission'; +import { appStore } from '/@/store/modules/app'; +import { getSlot } from '/@/utils/helper/tsxHelper'; + +export default defineComponent({ + name: 'Authority', + props: { + // 指定角色可见 + value: { + type: [Number, Array, String] as PropType, + default: '', + }, + }, + setup(props, { slots }) { + const getModeRef = computed(() => { + return appStore.getProjectConfig.permissionMode; + }); + /** + * 渲染角色按钮 + */ + function renderRoleAuth() { + const { value } = props; + if (!value) { + return getSlot(slots, 'default'); + } + const { hasPermission } = usePermission(); + return hasPermission(value) ? getSlot(slots, 'default') : null; + } + + /** + * 渲染编码按钮 + * 这里只判断是否包含,具体实现可以根据项目自行写逻辑 + */ + function renderCodeAuth() { + const { value } = props; + if (!value) { + return getSlot(slots, 'default'); + } + const { hasPermission } = usePermission(); + return hasPermission(value) ? getSlot(slots, 'default') : null; + } + return () => { + const mode = unref(getModeRef); + // 基于角色渲染 + if (mode === PermissionModeEnum.ROLE) { + return renderRoleAuth(); + } + // 基于后台编码渲染 + if (mode === PermissionModeEnum.BACK) { + return renderCodeAuth(); + } + return getSlot(slots, 'default'); + }; + }, +}); diff --git a/src/components/Basic/index.ts b/src/components/Basic/index.ts new file mode 100644 index 000000000..b7b24919c --- /dev/null +++ b/src/components/Basic/index.ts @@ -0,0 +1,4 @@ +export { default as BasicArrow } from './src/BasicArrow.vue'; +export { default as BasicHelp } from './src/BasicHelp'; +export { default as BasicTitle } from './src/BasicTitle.vue'; +export { default as BasicEmpty } from './src/BasicEmpty.vue'; diff --git a/src/components/Basic/src/BasicArrow.vue b/src/components/Basic/src/BasicArrow.vue new file mode 100644 index 000000000..f0c6c467e --- /dev/null +++ b/src/components/Basic/src/BasicArrow.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/components/Basic/src/BasicEmpty.vue b/src/components/Basic/src/BasicEmpty.vue new file mode 100644 index 000000000..f0760d150 --- /dev/null +++ b/src/components/Basic/src/BasicEmpty.vue @@ -0,0 +1,28 @@ + + diff --git a/src/components/Basic/src/BasicHelp.less b/src/components/Basic/src/BasicHelp.less new file mode 100644 index 000000000..1b8e1d334 --- /dev/null +++ b/src/components/Basic/src/BasicHelp.less @@ -0,0 +1,18 @@ +@import (reference) '../../../design/index.less'; + +.base-help { + display: inline-block; + font-size: 14px; + color: @text-color-help-dark; + cursor: pointer; + + &:hover { + color: @primary-color; + } + + &__wrap { + p { + margin-bottom: 0; + } + } +} diff --git a/src/components/Basic/src/BasicHelp.tsx b/src/components/Basic/src/BasicHelp.tsx new file mode 100644 index 000000000..4a4cf8cfd --- /dev/null +++ b/src/components/Basic/src/BasicHelp.tsx @@ -0,0 +1,107 @@ +import type { PropType } from 'vue'; + +import { Tooltip } from 'ant-design-vue'; +import { InfoCircleOutlined } from '@ant-design/icons-vue'; +import { defineComponent, computed, unref } from 'vue'; + +import { getPopupContainer } from '/@/utils'; + +import { isString, isArray } from '/@/utils/is'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import './BasicHelp.less'; +export default defineComponent({ + name: 'BaseHelp', + props: { + // max-width + maxWidth: { + type: String as PropType, + default: '600px', + }, + // Whether to display the serial number + showIndex: { + type: Boolean as PropType, + default: false, + }, + // Text list + text: { + type: [Array, String] as PropType, + }, + // color + color: { + type: String as PropType, + default: '#ffffff', + }, + fontSize: { + type: String as PropType, + default: '14px', + }, + absolute: { + type: Boolean as PropType, + default: false, + }, + // 定位 + position: { + type: [Object] as PropType, + default: () => ({ + position: 'absolute', + left: 0, + bottom: 0, + }), + }, + }, + setup(props, { slots }) { + const getOverlayStyleRef = computed(() => { + return { + maxWidth: props.maxWidth, + }; + }); + const getWrapStyleRef = computed(() => { + return { + color: props.color, + fontSize: props.fontSize, + }; + }); + const getMainStyleRef = computed(() => { + return props.absolute ? props.position : {}; + }); + + /** + * @description: 渲染内容 + */ + const renderTitle = () => { + const list = props.text; + if (isString(list)) { + return

{list}

; + } + if (isArray(list)) { + return list.map((item, index) => { + return ( +

+ {props.showIndex ? `${index + 1}. ` : ''} + {item} +

+ ); + }); + } + return null; + }; + return () => ( + {renderTitle()}) as any} + placement="right" + overlayStyle={unref(getOverlayStyleRef)} + autoAdjustOverflow={true} + overlayClassName="base-help__wrap" + getPopupContainer={() => getPopupContainer()} + > + {{ + default: () => ( + + {getSlot(slots) || } + + ), + }} + + ); + }, +}); diff --git a/src/components/Basic/src/BasicTitle.vue b/src/components/Basic/src/BasicTitle.vue new file mode 100644 index 000000000..108f65f69 --- /dev/null +++ b/src/components/Basic/src/BasicTitle.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/Breadcrumb/Breadcrumb.vue b/src/components/Breadcrumb/Breadcrumb.vue new file mode 100644 index 000000000..b37bd0432 --- /dev/null +++ b/src/components/Breadcrumb/Breadcrumb.vue @@ -0,0 +1,100 @@ + + + + diff --git a/src/components/Breadcrumb/BreadcrumbItem.vue b/src/components/Breadcrumb/BreadcrumbItem.vue new file mode 100644 index 000000000..61a8b6928 --- /dev/null +++ b/src/components/Breadcrumb/BreadcrumbItem.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/components/Button/index.vue b/src/components/Button/index.vue new file mode 100644 index 000000000..5d4f136a2 --- /dev/null +++ b/src/components/Button/index.vue @@ -0,0 +1,88 @@ + + diff --git a/src/components/Button/types.ts b/src/components/Button/types.ts new file mode 100644 index 000000000..b2cf3c472 --- /dev/null +++ b/src/components/Button/types.ts @@ -0,0 +1,66 @@ +import { VNodeChild } from 'vue'; + +export interface BasicButtonProps { + /** + * can be set to primary ghost dashed danger(added in 2.7) or omitted (meaning default) + * @default 'default' + * @type string + */ + type?: 'primary' | 'danger' | 'dashed' | 'ghost' | 'default'; + + /** + * set the original html type of button + * @default 'button' + * @type string + */ + htmlType?: 'button' | 'submit' | 'reset' | 'menu'; + + /** + * set the icon of button + * @type string + */ + icon?: VNodeChild | JSX.Element; + + /** + * can be set to circle or circle-outline or omitted + * @type string + */ + shape?: 'circle' | 'circle-outline'; + + /** + * can be set to small large or omitted + * @default 'default' + * @type string + */ + size?: 'small' | 'large' | 'default'; + + /** + * set the loading status of button + * @default false + * @type boolean | { delay: number } + */ + loading?: boolean | { delay: number }; + + /** + * disabled state of button + * @default false + * @type boolean + */ + disabled?: boolean; + + /** + * make background transparent and invert text and border colors, added in 2.7 + * @default false + * @type boolean + */ + ghost?: boolean; + + /** + * option to fit button width to its parent width + * @default false + * @type boolean + */ + block?: boolean; + + onClick?: (e?: Event) => void; +} diff --git a/src/components/ClickOutSide/index.vue b/src/components/ClickOutSide/index.vue new file mode 100644 index 000000000..4a9965f1c --- /dev/null +++ b/src/components/ClickOutSide/index.vue @@ -0,0 +1,21 @@ + + diff --git a/src/components/Container/index.ts b/src/components/Container/index.ts new file mode 100644 index 000000000..b6216d020 --- /dev/null +++ b/src/components/Container/index.ts @@ -0,0 +1,5 @@ +export { default as ScrollContainer } from './src/ScrollContainer.vue'; +export { default as CollapseContainer } from './src/collapse/CollapseContainer.vue'; +export { default as LazyContainer } from './src/LazyContainer'; + +export * from './src/types.d'; diff --git a/src/components/Container/src/LazyContainer.less b/src/components/Container/src/LazyContainer.less new file mode 100644 index 000000000..3e13b115a --- /dev/null +++ b/src/components/Container/src/LazyContainer.less @@ -0,0 +1,27 @@ +.lazy-container-enter { + opacity: 0; +} + +.lazy-container-enter-to { + opacity: 1; +} + +.lazy-container-enter-from, +.lazy-container-enter-active { + position: absolute; + top: 0; + width: 100%; + transition: opacity 0.3s 0.2s; +} + +.lazy-container-leave { + opacity: 1; +} + +.lazy-container-leave-to { + opacity: 0; +} + +.lazy-container-leave-active { + transition: opacity 0.5s; +} diff --git a/src/components/Container/src/LazyContainer.tsx b/src/components/Container/src/LazyContainer.tsx new file mode 100644 index 000000000..1c3bf4fcc --- /dev/null +++ b/src/components/Container/src/LazyContainer.tsx @@ -0,0 +1,200 @@ +import type { PropType } from 'vue'; + +import { + defineComponent, + reactive, + onMounted, + ref, + unref, + onUnmounted, + TransitionGroup, +} from 'vue'; + +import { Skeleton } from 'ant-design-vue'; +import { useRaf } from '/@/hooks/event/useRaf'; +import { useTimeout } from '/@/hooks/core/useTimeout'; +import { getListeners, getSlot } from '/@/utils/helper/tsxHelper'; + +import './LazyContainer.less'; + +interface State { + isInit: boolean; + loading: boolean; + intersectionObserverInstance: IntersectionObserver | null; +} +export default defineComponent({ + name: 'LazyContainer', + emits: ['before-init', 'init'], + props: { + // 等待时间,如果指定了时间,不论可见与否,在指定时间之后自动加载 + timeout: { + type: Number as PropType, + default: 8000, + // default: 8000, + }, + // 组件所在的视口,如果组件是在页面容器内滚动,视口就是该容器 + viewport: { + type: (typeof window !== 'undefined' ? window.HTMLElement : Object) as PropType, + default: () => null, + }, + // 预加载阈值, css单位 + threshold: { + type: String as PropType, + default: '0px', + }, + + // 视口的滚动方向, vertical代表垂直方向,horizontal代表水平方向 + direction: { + type: String as PropType<'vertical' | 'horizontal'>, + default: 'vertical', + }, + // 包裹组件的外层容器的标签名 + tag: { + type: String as PropType, + default: 'div', + }, + + maxWaitingTime: { + type: Number as PropType, + default: 80, + }, + + // 是否在不可见的时候销毁 + autoDestory: { + type: Boolean as PropType, + default: false, + }, + + // transition name + transitionName: { + type: String as PropType, + default: 'lazy-container', + }, + }, + setup(props, { attrs, emit, slots }) { + const elRef = ref(null); + const state = reactive({ + isInit: false, + loading: false, + intersectionObserverInstance: null, + }); + + // If there is a set delay time, it will be executed immediately + function immediateInit() { + const { timeout } = props; + timeout && + useTimeout(() => { + init(); + }, timeout); + } + + function init() { + // At this point, the skeleton component is about to be switched + emit('before-init'); + // At this point you can prepare to load the resources of the lazy-loaded component + state.loading = true; + + requestAnimationFrameFn(() => { + state.isInit = true; + emit('init'); + }); + } + function requestAnimationFrameFn(callback: () => any) { + // Prevent waiting too long without executing the callback + // Set the maximum waiting time + useTimeout(() => { + if (state.isInit) { + return; + } + callback(); + }, props.maxWaitingTime || 80); + + const { requestAnimationFrame } = useRaf(); + + return requestAnimationFrame; + } + function initIntersectionObserver() { + const { timeout, direction, threshold, viewport } = props; + if (timeout) { + return; + } + // According to the scrolling direction to construct the viewport margin, used to load in advance + let rootMargin; + switch (direction) { + case 'vertical': + rootMargin = `${threshold} 0px`; + break; + case 'horizontal': + rootMargin = `0px ${threshold}`; + break; + } + try { + // Observe the intersection of the viewport and the component container + state.intersectionObserverInstance = new window.IntersectionObserver(intersectionHandler, { + rootMargin, + root: viewport, + threshold: [0, Number.MIN_VALUE, 0.01], + }); + + const el = unref(elRef); + + state.intersectionObserverInstance.observe(el.$el); + } catch (e) { + init(); + } + } + // Cross-condition change handling function + function intersectionHandler(entries: any[]) { + const isIntersecting = entries[0].isIntersecting || entries[0].intersectionRatio; + if (isIntersecting) { + init(); + if (state.intersectionObserverInstance) { + const el = unref(elRef); + state.intersectionObserverInstance.unobserve(el.$el); + } + } + // else { + // const { autoDestory } = props; + // autoDestory && destory(); + // } + } + // function destory() { + // emit('beforeDestory'); + // state.loading = false; + // nextTick(() => { + // emit('destory'); + // }); + // } + + immediateInit(); + onMounted(() => { + initIntersectionObserver(); + }); + onUnmounted(() => { + // Cancel the observation before the component is destroyed + if (state.intersectionObserverInstance) { + const el = unref(elRef); + state.intersectionObserverInstance.unobserve(el.$el); + } + }); + + function renderContent() { + const { isInit, loading } = state; + if (isInit) { + return
{getSlot(slots, 'default', { loading })}
; + } + if (slots.skeleton) { + return
{getSlot(slots, 'skeleton') || }
; + } + return null; + } + return () => { + const { tag, transitionName } = props; + return ( + + {() => renderContent()} + + ); + }; + }, +}); diff --git a/src/components/Container/src/ScrollContainer.vue b/src/components/Container/src/ScrollContainer.vue new file mode 100644 index 000000000..37437ad86 --- /dev/null +++ b/src/components/Container/src/ScrollContainer.vue @@ -0,0 +1,80 @@ + + + + diff --git a/src/components/Container/src/collapse/CollapseContainer.vue b/src/components/Container/src/collapse/CollapseContainer.vue new file mode 100644 index 000000000..72813ac6d --- /dev/null +++ b/src/components/Container/src/collapse/CollapseContainer.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/components/Container/src/collapse/CollapseHeader.vue b/src/components/Container/src/collapse/CollapseHeader.vue new file mode 100644 index 000000000..9dab03e41 --- /dev/null +++ b/src/components/Container/src/collapse/CollapseHeader.vue @@ -0,0 +1,32 @@ + + diff --git a/src/components/Container/src/types.d.ts b/src/components/Container/src/types.d.ts new file mode 100644 index 000000000..86c03bea6 --- /dev/null +++ b/src/components/Container/src/types.d.ts @@ -0,0 +1,17 @@ +export type ScrollType = 'default' | 'main'; + +export interface CollapseContainerOptions { + canExpand?: boolean; + title?: string; + helpMessage?: Array | string; +} +export interface ScrollContainerOptions { + enableScroll?: boolean; + type?: ScrollType; +} + +export type ScrollActionType = RefType<{ + scrollBottom: () => void; + getScrollWrap: () => Nullable; + scrollTo: (top: number) => void; +}>; diff --git a/src/components/ContextMenu/index.ts b/src/components/ContextMenu/index.ts new file mode 100644 index 000000000..fb08e6405 --- /dev/null +++ b/src/components/ContextMenu/index.ts @@ -0,0 +1,65 @@ +import contextMenuVue from './src/index'; +import { isClient } from '/@/utils/is'; +import { Options, Props } from './src/types'; +import { createApp } from 'vue'; +const menuManager: { + doms: Element[]; + resolve: Fn; +} = { + doms: [], + resolve: () => {}, +}; +export const createContextMenu = function (options: Options) { + const { event } = options || {}; + try { + event.preventDefault(); + } catch (e) { + console.log(e); + } + + if (!isClient) return; + return new Promise((resolve) => { + const wrapDom = document.createElement('div'); + const propsData: Partial = {}; + if (options.styles !== undefined) propsData.styles = options.styles; + if (options.items !== undefined) propsData.items = options.items; + if (options.event !== undefined) { + propsData.customEvent = event; + propsData.axis = { x: event.clientX, y: event.clientY }; + } + createApp(contextMenuVue, propsData).mount(wrapDom); + const bodyClick = function () { + menuManager.resolve(''); + }; + const contextMenuDom = wrapDom.children[0]; + menuManager.doms.push(contextMenuDom); + const remove = function () { + menuManager.doms.forEach((dom: Element) => { + try { + document.body.removeChild(dom); + } catch (error) {} + }); + document.body.removeEventListener('click', bodyClick); + document.body.removeEventListener('scroll', bodyClick); + try { + (wrapDom as any) = null; + } catch (error) {} + }; + menuManager.resolve = function (...arg: any) { + resolve(arg[0]); + remove(); + }; + remove(); + document.body.appendChild(contextMenuDom); + document.body.addEventListener('click', bodyClick); + document.body.addEventListener('scroll', bodyClick); + }); +}; +export const unMountedContextMenu = function () { + if (menuManager) { + menuManager.resolve(''); + menuManager.doms = []; + } +}; + +export * from './src/types'; diff --git a/src/components/ContextMenu/src/index.less b/src/components/ContextMenu/src/index.less new file mode 100644 index 000000000..acda218e7 --- /dev/null +++ b/src/components/ContextMenu/src/index.less @@ -0,0 +1,49 @@ +@import (reference) '../../../design/index.less'; + +.context-menu { + position: fixed; + top: 0; + left: 0; + z-index: 1500; + display: block; + width: 156px; + min-width: 10rem; + margin: 0; + list-style: none; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 0.25rem; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.1), + 0 1px 5px 0 rgba(0, 0, 0, 0.06); + background-clip: padding-box; + user-select: none; + + &.hidden { + display: none !important; + } + + &__item { + a { + display: inline-block; + width: 100%; + padding: 10px 14px; + + &:hover { + color: @text-color-base; + background: #eee; + } + } + + &.disabled { + a { + color: @disabled-color; + cursor: not-allowed; + + &:hover { + color: @disabled-color; + background: unset; + } + } + } + } +} diff --git a/src/components/ContextMenu/src/index.tsx b/src/components/ContextMenu/src/index.tsx new file mode 100644 index 000000000..f008d86de --- /dev/null +++ b/src/components/ContextMenu/src/index.tsx @@ -0,0 +1,90 @@ +import { + defineComponent, + nextTick, + onMounted, + reactive, + computed, + ref, + unref, + onUnmounted, +} from 'vue'; +import { props } from './props'; +import Icon from '/@/components/Icon'; +import type { ContextMenuItem } from './types'; +import './index.less'; +const prefixCls = 'context-menu'; +export default defineComponent({ + name: 'ContextMenu', + props, + setup(props) { + const wrapRef = ref>(null); + const state = reactive({ + show: false, + }); + onMounted(() => { + nextTick(() => { + state.show = true; + }); + }); + onUnmounted(() => { + const el = unref(wrapRef); + el && document.body.removeChild(el); + }); + const getStyle = computed(() => { + const { axis, items, styles, width } = props; + const { x, y } = axis || { x: 0, y: 0 }; + const menuHeight = (items || []).length * 40; + const menuWidth = width; + const body = document.body; + return { + ...(styles as any), + width: `${width}px`, + left: (body.clientWidth < x + menuWidth ? x - menuWidth : x) + 'px', + top: (body.clientHeight < y + menuHeight ? y - menuHeight : y) + 'px', + }; + }); + function handleAction(item: ContextMenuItem, e: MouseEvent) { + const { handler, disabled } = item; + if (disabled) { + return; + } + state.show = false; + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + + handler && handler(); + } + function renderContent(item: ContextMenuItem) { + const { icon, label } = item; + + const { showIcon } = props; + return ( + + {showIcon && icon && } + {label} + + ); + } + function renderMenuItem(items: ContextMenuItem[]) { + return items.map((item) => { + const { disabled, label } = item; + + return ( +
  • + {renderContent(item)} +
  • + ); + }); + } + return () => { + const { items } = props; + return ( +
      + {renderMenuItem(items)} +
    + ); + }; + }, +}); diff --git a/src/components/ContextMenu/src/props.ts b/src/components/ContextMenu/src/props.ts new file mode 100644 index 000000000..b0563afa0 --- /dev/null +++ b/src/components/ContextMenu/src/props.ts @@ -0,0 +1,40 @@ +import type { PropType } from 'vue'; +import type { Axis, ContextMenuItem } from './types'; +export const props = { + width: { + type: Number as PropType, + default: 180, + }, + customEvent: { + type: Object as PropType, + default: null, + }, + // 样式 + styles: { + type: Object as PropType, + default: null, + }, + showIcon: { + // 是否显示icon + type: Boolean as PropType, + default: true, + }, + axis: { + // 鼠标右键点击的位置 + type: Object as PropType, + default() { + return { x: 0, y: 0 }; + }, + }, + items: { + // 最重要的列表,没有的话直接不显示 + type: Array as PropType, + default() { + return []; + }, + }, + resolve: { + type: Function as PropType, + default: null, + }, +}; diff --git a/src/components/ContextMenu/src/types.ts b/src/components/ContextMenu/src/types.ts new file mode 100644 index 000000000..6f9b51643 --- /dev/null +++ b/src/components/ContextMenu/src/types.ts @@ -0,0 +1,30 @@ +export interface Axis { + x: number; + y: number; +} + +export interface ContextMenuItem { + label: string; + icon?: string; + disabled?: boolean; + handler?: Fn; + divider?: boolean; + children?: ContextMenuItem[]; +} +export interface Options { + event: MouseEvent; + icon?: string; + styles?: any; + items?: ContextMenuItem[]; +} + +export type Props = { + resolve?: (...arg: any) => void; + event?: MouseEvent; + styles?: any; + items: ContextMenuItem[]; + customEvent?: MouseEvent; + axis?: Axis; + width?: number; + showIcon?: boolean; +}; diff --git a/src/components/Description/index.ts b/src/components/Description/index.ts new file mode 100644 index 000000000..5736189ab --- /dev/null +++ b/src/components/Description/index.ts @@ -0,0 +1,3 @@ +export { default as Description } from './src/index'; +export * from './src/types'; +export { useDescription } from './src/useDescription'; diff --git a/src/components/Description/src/index.tsx b/src/components/Description/src/index.tsx new file mode 100644 index 000000000..84803d7e5 --- /dev/null +++ b/src/components/Description/src/index.tsx @@ -0,0 +1,144 @@ +import { defineComponent, computed, ref, unref } from 'vue'; +import { Descriptions } from 'ant-design-vue'; +import { CollapseContainer, CollapseContainerOptions } from '/@/components/Container/index'; +import type { DescOptions, DescInstance, DescItem } from './types'; +import descProps from './props'; + +import { isFunction } from '/@/utils/is'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import { cloneDeep } from 'lodash-es'; +import { deepMerge } from '/@/utils'; + +const prefixCls = 'description'; +export default defineComponent({ + props: descProps, + emits: ['register'], + setup(props, { attrs, slots, emit }) { + // props来自设置 + const propsRef = ref | null>(null); + // 自定义title组件:获得title + const getMergeProps = computed(() => { + return { + ...props, + ...unref(propsRef), + }; + }); + const getProps = computed(() => { + const opt = { + ...props, + ...(unref(propsRef) || {}), + title: undefined, + }; + return opt; + }); + /** + * @description: 是否使用标题 + */ + const useWrapper = computed(() => { + return !!unref(getMergeProps).title; + }); + /** + * @description: 获取配置Collapse + */ + const getCollapseOptions = computed( + (): CollapseContainerOptions => { + return { + // 默认不能展开 + canExpand: false, + ...unref(getProps).collapseOptions, + }; + } + ); + /** + * @description:设置desc + */ + function setDescProps(descProps: Partial): void { + // 保留上一次的setDrawerProps + const mergeProps = deepMerge(unref(propsRef) || {}, descProps); + propsRef.value = cloneDeep(mergeProps); + } + const methods: DescInstance = { + setDescProps, + }; + emit('register', methods); + + // 防止换行 + function renderLabel({ label, labelMinWidth, labelStyle }: DescItem) { + if (!labelStyle && !labelMinWidth) { + return label; + } + return ( +
    + {label} +
    + ); + } + + function renderItem() { + const { schema } = unref(getProps); + return unref(schema).map((item) => { + const { render, field, span, show, contentMinWidth } = item; + const { data } = unref(getProps) as any; + if (show && isFunction(show) && !show(data)) { + return null; + } + const getContent = () => + isFunction(render) + ? render(data && data[field], data) + : unref(data) && unref(data)[field]; + + const width = contentMinWidth; + return ( + + {() => + contentMinWidth ? ( +
    + {getContent()} +
    + ) : ( + getContent() + ) + } +
    + ); + }); + } + const renderDesc = () => { + return ( + + {() => renderItem()} + + ); + }; + const renderContainer = () => { + const content = props.useCollapse ? renderDesc() :
    {renderDesc()}
    ; + // 减少dom层级 + return props.useCollapse ? ( + + {{ + default: () => content, + action: () => getSlot(slots, 'action'), + }} + + ) : ( + content + ); + }; + + return () => (unref(useWrapper) ? renderContainer() : renderDesc()); + }, +}); diff --git a/src/components/Description/src/props.ts b/src/components/Description/src/props.ts new file mode 100644 index 000000000..661c424b4 --- /dev/null +++ b/src/components/Description/src/props.ts @@ -0,0 +1,39 @@ +import type { PropType } from 'vue'; +import type { DescItem } from './types'; + +export default { + useCollapse: { + type: Boolean as PropType, + default: true, + }, + title: { + type: String as PropType, + default: '', + }, + size: { + type: String as PropType<'small' | 'default' | 'middle' | undefined>, + default: 'small', + }, + bordered: { + type: Boolean as PropType, + default: true, + }, + column: { + type: [Number, Object] as PropType, + default: () => { + return { xxl: 4, xl: 3, lg: 3, md: 3, sm: 2, xs: 1 }; + }, + }, + collapseOptions: { + type: Object as PropType, + default: null, + }, + schema: { + type: Array as PropType>, + default: () => [], + }, + data: { + type: Object as PropType, + default: null, + }, +}; diff --git a/src/components/Description/src/types.ts b/src/components/Description/src/types.ts new file mode 100644 index 000000000..df10e87f7 --- /dev/null +++ b/src/components/Description/src/types.ts @@ -0,0 +1,95 @@ +import type { VNode } from 'vue'; +import type { CollapseContainerOptions } from '/@/components/Container/index'; + +export interface DescItem { + // 最小宽度 + labelMinWidth?: number; + + contentMinWidth?: number; + + labelStyle?: any; + + field: string; + label: string; + // 和并列 + span?: number; + show?: (...arg: any) => boolean; + // render + render?: (val: string, data: any) => VNode | undefined | Element | string | number; +} + +export interface DescOptions { + // 是否包含collapse组件 + useCollapse?: boolean; + /** + * item配置 + * @type DescItem + */ + schema: DescItem[]; + /** + * 数据 + * @type object + */ + data: object; + /** + * 内置的CollapseContainer组件配置 + * @type CollapseContainerOptions + */ + collapseOptions?: CollapseContainerOptions; + /** + * descriptions size type + * @default 'default' + * @type string + */ + size?: 'default' | 'middle' | 'small'; + + /** + * custom prefixCls + * @type string + */ + prefixCls?: string; + + /** + * whether descriptions have border + * @default false + * @type boolean + */ + bordered?: boolean; + + /** + * custom title + * @type any + */ + title?: any; + + /** + * the number of descriptionsitem in one line + * @default 3 + * @type number | object + */ + column?: number | object; + + /** + * descriptions layout + * @default 'horizontal' + * @type string + */ + layout?: 'horizontal' | 'vertical'; + + /** + * whether have colon in descriptionsitem + * @default true + * @type boolean + */ + colon?: boolean; +} + +export interface DescInstance { + setDescProps(descProps: Partial): void; +} + +export type Register = (descInstance: DescInstance) => void; +/** + * @description: + */ +export type UseDescReturnType = [Register, DescInstance]; diff --git a/src/components/Description/src/useDescription.ts b/src/components/Description/src/useDescription.ts new file mode 100644 index 000000000..ddf3512c3 --- /dev/null +++ b/src/components/Description/src/useDescription.ts @@ -0,0 +1,27 @@ +import { ref, getCurrentInstance, unref } from 'vue'; +import { isProdMode } from '/@/utils/env'; + +import type { DescOptions, DescInstance, UseDescReturnType } from './types'; + +export function useDescription(props?: Partial): UseDescReturnType { + if (!getCurrentInstance()) { + throw new Error('Please put useDescription function in the setup function!'); + } + const descRef = ref(null); + const loadedRef = ref(false); + + function getDescription(instance: DescInstance) { + if (unref(loadedRef) && isProdMode()) { + return; + } + descRef.value = instance; + props && instance.setDescProps(props); + loadedRef.value = true; + } + const methods: DescInstance = { + setDescProps: (descProps: Partial): void => { + unref(descRef)!.setDescProps(descProps); + }, + }; + return [getDescription, methods]; +} diff --git a/src/components/Drawer/index.ts b/src/components/Drawer/index.ts new file mode 100644 index 000000000..b3884399c --- /dev/null +++ b/src/components/Drawer/index.ts @@ -0,0 +1,4 @@ +export { default as BasicDrawer } from './src/BasicDrawer'; + +export { useDrawer, useDrawerInner } from './src/useDrawer'; +export * from './src/types'; diff --git a/src/components/Drawer/src/BasicDrawer.tsx b/src/components/Drawer/src/BasicDrawer.tsx new file mode 100644 index 000000000..cd51648bc --- /dev/null +++ b/src/components/Drawer/src/BasicDrawer.tsx @@ -0,0 +1,279 @@ +import { Drawer, Row, Col, Button } from 'ant-design-vue'; +import { + defineComponent, + ref, + computed, + watchEffect, + watch, + unref, + getCurrentInstance, + nextTick, + toRaw, +} from 'vue'; +import { BasicTitle } from '/@/components/Basic'; +import { ScrollContainer, ScrollContainerOptions } from '/@/components/Container/index'; +import { FullLoading } from '/@/components/Loading/index'; + +import { getSlot } from '/@/utils/helper/tsxHelper'; + +import { DrawerInstance, DrawerProps, DrawerType } from './types'; + +import { basicProps } from './props'; +import { isFunction, isNumber } from '/@/utils/is'; +import { LeftOutlined } from '@ant-design/icons-vue'; +// import { appStore } from '/@/store/modules/app'; +// import { useRouter } from 'vue-router'; +import { buildUUID } from '/@/utils/uuid'; +import { deepMerge } from '/@/utils'; +import './index.less'; + +const prefixCls = 'basic-drawer'; +export default defineComponent({ + // inheritAttrs: false, + props: basicProps, + emits: ['visible-change', 'ok', 'close', 'register'], + setup(props, { slots, emit, attrs }) { + // const { currentRoute } = useRouter(); + const scrollRef = ref(null); + /** + * @description: 获取配置ScrollContainer + */ + const getScrollOptions = computed( + (): ScrollContainerOptions => { + return { + ...(props.scrollOptions as any), + }; + } + ); + + const visibleRef = ref(false); + const propsRef = ref | null>(null); + + // 自定义title组件:获得title + const getMergeProps = computed((): any => { + return deepMerge(toRaw(props), unref(propsRef)); + }); + + const getProps = computed(() => { + const opt: any = { + // @ts-ignore + placement: 'right', + ...attrs, + ...props, + ...(unref(propsRef) as any), + visible: unref(visibleRef), + }; + opt.title = undefined; + + if (opt.drawerType === DrawerType.DETAIL) { + if (!opt.width) { + opt.width = '100%'; + } + opt.wrapClassName = opt.wrapClassName + ? `${opt.wrapClassName} ${prefixCls}__detail` + : `${prefixCls}__detail`; + // opt.maskClosable = false; + if (!opt.getContainer) { + opt.getContainer = `.default-layout__main`; + } + } + return opt; + }); + watchEffect(() => { + visibleRef.value = props.visible; + }); + watch( + () => visibleRef.value, + (visible) => { + // appStore.commitLockMainScrollState(visible); + nextTick(() => { + emit('visible-change', visible); + }); + }, + { + immediate: false, + } + ); + + // watch( + // () => currentRoute.value.path, + // () => { + // if (unref(visibleRef)) { + // visibleRef.value = false; + // } + // } + // ); + function scrollBottom() { + const scroll = unref(scrollRef); + if (scroll) { + scroll.scrollBottom(); + } + } + function scrollTo(to: number) { + const scroll = unref(scrollRef); + if (scroll) { + scroll.scrollTo(to); + } + } + function getScrollWrap() { + const scroll = unref(scrollRef); + if (scroll) { + return scroll.getScrollWrap(); + } + return null; + } + // 取消事件 + async function onClose(e: any) { + const { closeFunc } = unref(getProps); + emit('close', e); + if (closeFunc && isFunction(closeFunc)) { + const res = await closeFunc(); + res && (visibleRef.value = false); + return; + } + visibleRef.value = false; + } + + function setDrawerProps(props: Partial): void { + // 保留上一次的setDrawerProps + propsRef.value = deepMerge(unref(propsRef) || {}, props); + if (Reflect.has(props, 'visible')) { + visibleRef.value = !!props.visible; + } + } + + // 底部按钮自定义实现, + const getFooterHeight = computed(() => { + const { footerHeight, showFooter }: DrawerProps = unref(getProps); + if (showFooter && footerHeight) { + return isNumber(footerHeight) ? `${footerHeight}px` : `${footerHeight.replace('px', '')}px`; + } + return 0; + }); + function renderFooter() { + const { + showCancelBtn, + cancelButtonProps, + cancelText, + showOkBtn, + okType, + okText, + okButtonProps, + confirmLoading, + showFooter, + }: DrawerProps = unref(getProps); + + return ( + getSlot(slots, 'footer') || + (showFooter && ( +
    + {getSlot(slots, 'insertFooter')} + + {showCancelBtn && ( + + )} + {getSlot(slots, 'centerFooter')} + {showOkBtn && ( + + )} + + {getSlot(slots, 'appendFooter')} +
    + )) + ); + } + + function renderHeader() { + const { title } = unref(getMergeProps); + return props.drawerType === DrawerType.DETAIL ? ( + getSlot(slots, 'title') || ( + + {() => ( + <> + {props.showDetailBack && ( + + {() => ( + + )} + + )} + {title && ( + + {() => title} + + )} + {getSlot(slots, 'titleToolbar')} + + )} + + ) + ) : ( + {() => title || getSlot(slots, 'title')} + ); + } + + const currentInstance = getCurrentInstance() as any; + if (getCurrentInstance()) { + currentInstance.scrollBottom = scrollBottom; + currentInstance.scrollTo = scrollTo; + currentInstance.getScrollWrap = getScrollWrap; + } + const drawerInstance: DrawerInstance = { + setDrawerProps: setDrawerProps, + }; + + const uuid = buildUUID(); + emit('register', drawerInstance, uuid); + + return () => { + const footerHeight = unref(getFooterHeight); + + return ( + + {{ + title: () => renderHeader(), + default: () => ( + <> + + + {() => getSlot(slots, 'default')} + + {renderFooter()} + + ), + }} + + ); + }; + }, +}); diff --git a/src/components/Drawer/src/index.less b/src/components/Drawer/src/index.less new file mode 100644 index 000000000..a0d7e344b --- /dev/null +++ b/src/components/Drawer/src/index.less @@ -0,0 +1,63 @@ +@import (reference) '../../../design/index.less'; +@header-height: 50px; +@footer-height: 60px; + +.basic-drawer { + .ant-drawer-wrapper-body { + overflow: hidden; + } + + .ant-drawer-close { + &:hover { + color: @error-color; + } + } + + .ant-drawer-body { + height: calc(100% - @header-height); + padding: 0; + background-color: @background-color-dark; + + .scrollbar__wrap { + padding: 16px; + } + } + + &__detail { + position: absolute; + + &-header { + height: 100%; + } + + .ant-drawer-header { + width: 100%; + height: @header-height; + padding: 0; + border-top: 1px solid @border-color-base; + box-sizing: border-box; + } + + .ant-drawer-title { + height: 100%; + } + + .ant-drawer-close { + height: @header-height; + line-height: @header-height; + } + + .scrollbar__wrap { + padding: 0; + } + } + + &__footer { + height: @footer-height; + padding: 0 26px; + line-height: @footer-height; + text-align: right; + background: #fff; + border-top: 1px solid @border-color-base; + } +} diff --git a/src/components/Drawer/src/props.ts b/src/components/Drawer/src/props.ts new file mode 100644 index 000000000..3d2cc84c2 --- /dev/null +++ b/src/components/Drawer/src/props.ts @@ -0,0 +1,85 @@ +import type { PropType } from 'vue'; +import { DrawerType } from './types'; +// import {DrawerProps} from './types' +export const footerProps = { + confirmLoading: Boolean as PropType, + /** + * @description: 显示关闭按钮 + */ + showCancelBtn: { + type: Boolean as PropType, + default: true, + }, + cancelButtonProps: Object as PropType, + cancelText: { + type: String as PropType, + default: '关闭', + }, + /** + * @description: 显示确认按钮 + */ + showOkBtn: { + type: Boolean as PropType, + default: true, + }, + okButtonProps: Object as PropType, + okText: { + type: String as PropType, + default: '保存', + }, + okType: { + type: String as PropType, + default: 'primary', + }, + showFooter: { + type: Boolean as PropType, + default: false, + }, + footerHeight: { + type: [String, Number] as PropType, + default: 60, + }, +}; +export const basicProps = { + drawerType: { + type: Number as PropType, + default: DrawerType.DEFAULT, + }, + title: { + type: String as PropType, + default: '', + }, + showDetailBack: { + type: Boolean as PropType, + default: true, + }, + visible: { + type: Boolean as PropType, + default: false, + }, + loading: { + type: Boolean as PropType, + default: false, + }, + maskClosable: { + type: Boolean as PropType, + default: true, + }, + getContainer: { + type: [Object, String] as PropType, + }, + scrollOptions: { + type: Object as PropType, + default: null, + }, + closeFunc: { + type: [Function, Object] as PropType, + default: null, + }, + triggerWindowResize: { + type: Boolean as PropType, + default: false, + }, + destroyOnClose: Boolean as PropType, + ...footerProps, +}; diff --git a/src/components/Drawer/src/types.ts b/src/components/Drawer/src/types.ts new file mode 100644 index 000000000..3113bd2f1 --- /dev/null +++ b/src/components/Drawer/src/types.ts @@ -0,0 +1,194 @@ +import type { Button } from 'ant-design-vue/types/button/button'; +import type { CSSProperties, VNodeChild } from 'vue'; +import type { ScrollContainerOptions } from '/@/components/Container/index'; + +export interface DrawerInstance { + setDrawerProps: (props: Partial | boolean) => void; +} +export interface ReturnMethods extends DrawerInstance { + openDrawer: (visible?: boolean) => void; + transferDrawerData: (data: any) => void; +} +export type RegisterFn = (drawerInstance: DrawerInstance, uuid?: string) => void; + +export interface ReturnInnerMethods extends DrawerInstance { + closeDrawer: () => void; + changeLoading: (loading: boolean) => void; + changeOkLoading: (loading: boolean) => void; + receiveDrawerDataRef: any; +} + +export type UseDrawerReturnType = [RegisterFn, ReturnMethods]; +export type UseDrawerInnerReturnType = [RegisterFn, ReturnInnerMethods]; +export enum DrawerType { + DETAIL, + DEFAULT, +} + +export interface DrawerFooterProps { + showOkBtn: boolean; + showCancelBtn: boolean; + /** + * Text of the Cancel button + * @default 'cancel' + * @type string + */ + cancelText: string; + /** + * Text of the OK button + * @default 'OK' + * @type string + */ + okText: string; + + /** + * Button type of the OK button + * @default 'primary' + * @type string + */ + okType: 'primary' | 'danger' | 'dashed' | 'ghost' | 'default'; + /** + * The ok button props, follow jsx rules + * @type object + */ + okButtonProps: { props: Button; on: {} }; + + /** + * The cancel button props, follow jsx rules + * @type object + */ + cancelButtonProps: { props: Button; on: {} }; + /** + * Whether to apply loading visual effect for OK button or not + * @default false + * @type boolean + */ + confirmLoading: boolean; + + showFooter: boolean; + footerHeight: string | number; +} +export interface DrawerProps extends DrawerFooterProps { + drawerType: DrawerType; + loading?: boolean; + showDetailBack?: boolean; + visible?: boolean; + /** + * 内置的ScrollContainer组件配置 + * @type ScrollContainerOptions + */ + scrollOptions?: ScrollContainerOptions; + closeFunc?: () => Promise; + triggerWindowResize?: boolean; + /** + * Whether a close (x) button is visible on top right of the Drawer dialog or not. + * @default true + * @type boolean + */ + closable?: boolean; + + /** + * Whether to unmount child components on closing drawer or not. + * @default false + * @type boolean + */ + destroyOnClose?: boolean; + + /** + * Return the mounted node for Drawer. + * @default 'body' + * @type any ( HTMLElement| () => HTMLElement | string) + */ + getContainer?: () => HTMLElement | string; + + /** + * Whether to show mask or not. + * @default true + * @type boolean + */ + mask?: boolean; + + /** + * Clicking on the mask (area outside the Drawer) to close the Drawer or not. + * @default true + * @type boolean + */ + maskClosable?: boolean; + + /** + * Style for Drawer's mask element. + * @default {} + * @type object + */ + maskStyle?: CSSProperties; + + /** + * The title for Drawer. + * @type any (string | slot) + */ + title?: VNodeChild | JSX.Element; + + /** + * The class name of the container of the Drawer dialog. + * @type string + */ + wrapClassName?: string; + + /** + * Style of wrapper element which **contains mask** compare to `drawerStyle` + * @type object + */ + wrapStyle?: CSSProperties; + + /** + * Style of the popup layer element + * @type object + */ + drawerStyle?: CSSProperties; + + /** + * Style of floating layer, typically used for adjusting its position. + * @type object + */ + bodyStyle?: CSSProperties; + headerStyle?: CSSProperties; + + /** + * Width of the Drawer dialog. + * @default 256 + * @type string | number + */ + width?: string | number; + + /** + * placement is top or bottom, height of the Drawer dialog. + * @type string | number + */ + height?: string | number; + + /** + * The z-index of the Drawer. + * @default 1000 + * @type number + */ + zIndex?: number; + + /** + * The placement of the Drawer. + * @default 'right' + * @type string + */ + placement?: 'top' | 'right' | 'bottom' | 'left'; + afterVisibleChange?: (visible?: boolean) => void; + keyboard?: boolean; + + /** + * Specify a callback that will be called when a user clicks mask, close button or Cancel button. + */ + onClose?: (e?: Event) => void; +} +export interface DrawerActionType { + scrollBottom: () => void; + scrollTo: (to: number) => void; + getScrollWrap: () => Element | null; +} diff --git a/src/components/Drawer/src/useDrawer.ts b/src/components/Drawer/src/useDrawer.ts new file mode 100644 index 000000000..914611cdd --- /dev/null +++ b/src/components/Drawer/src/useDrawer.ts @@ -0,0 +1,100 @@ +import type { + UseDrawerReturnType, + DrawerInstance, + ReturnMethods, + DrawerProps, + UseDrawerInnerReturnType, +} from './types'; +import { ref, getCurrentInstance, onUnmounted, unref, reactive, computed } from 'vue'; +import { isProdMode } from '/@/utils/env'; + +const dataTransferRef = reactive({}); +/** + * @description: 适用于将drawer独立出去,外面调用 + */ +export function useDrawer(): UseDrawerReturnType { + if (!getCurrentInstance()) { + throw new Error('Please put useDrawer function in the setup function!'); + } + const drawerRef = ref(null); + const loadedRef = ref(false); + const uidRef = ref(''); + + function getDrawer(drawerInstance: DrawerInstance, uuid: string) { + uidRef.value = uuid; + isProdMode() && + onUnmounted(() => { + drawerRef.value = null; + loadedRef.value = null; + dataTransferRef[unref(uidRef)] = null; + }); + if (unref(loadedRef) && isProdMode() && drawerInstance === unref(drawerRef)) { + return; + } + drawerRef.value = drawerInstance; + loadedRef.value = true; + } + const getInstance = () => { + const instance = unref(drawerRef); + if (!instance) { + throw new Error('instance is undefined!'); + } + return instance; + }; + const methods: ReturnMethods = { + setDrawerProps: (props: Partial): void => { + getInstance().setDrawerProps(props); + }, + openDrawer: (visible = true): void => { + getInstance().setDrawerProps({ + visible: visible, + }); + }, + transferDrawerData(val: any) { + dataTransferRef[unref(uidRef)] = val; + }, + }; + + return [getDrawer, methods]; +} +export const useDrawerInner = (): UseDrawerInnerReturnType => { + const drawerInstanceRef = ref(null); + const currentInstall = getCurrentInstance(); + const uidRef = ref(''); + + if (!currentInstall) { + throw new Error('instance is undefined!'); + } + const getInstance = () => { + const instance = unref(drawerInstanceRef); + if (!instance) { + throw new Error('instance is undefined!'); + } + return instance; + }; + const register = (modalInstance: DrawerInstance, uuid: string) => { + uidRef.value = uuid; + drawerInstanceRef.value = modalInstance; + currentInstall.emit('register', modalInstance); + }; + return [ + register, + { + receiveDrawerDataRef: computed(() => { + return dataTransferRef[unref(uidRef)]; + }), + changeLoading: (loading = true) => { + getInstance().setDrawerProps({ loading }); + }, + changeOkLoading: (loading = true) => { + getInstance().setDrawerProps({ confirmLoading: loading }); + }, + closeDrawer: () => { + getInstance().setDrawerProps({ visible: false }); + }, + setDrawerProps: (props: Partial) => { + getInstance().setDrawerProps(props); + }, + }, + ]; +}; diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 000000000..f94352670 --- /dev/null +++ b/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,55 @@ +import { defineComponent, computed, unref } from 'vue'; +import { Dropdown, Menu } from 'ant-design-vue'; + +import Icon from '/@/components/Icon/index'; + +import { basicDropdownProps } from './props'; +import { getSlot } from '/@/utils/helper/tsxHelper'; + +export default defineComponent({ + name: 'Dropdown', + props: basicDropdownProps, + setup(props, { slots, emit, attrs }) { + const getMenuList = computed(() => props.dropMenuList); + + function handleClickMenu({ key }: any) { + const menu = unref(getMenuList)[key]; + emit('menuEvent', menu); + } + + function renderMenus() { + return ( + + {() => ( + <> + {unref(getMenuList).map((item, index) => { + const { disabled, icon, text, divider } = item; + + return [ + + {() => ( + <> + {icon && } + {text} + + )} + , + divider && , + ]; + })} + + )} + + ); + } + + return () => ( + + {{ + default: () => {getSlot(slots)}, + overlay: () => renderMenus(), + }} + + ); + }, +}); diff --git a/src/components/Dropdown/index.ts b/src/components/Dropdown/index.ts new file mode 100644 index 000000000..ddda25c6d --- /dev/null +++ b/src/components/Dropdown/index.ts @@ -0,0 +1,2 @@ +export { default as Dropdown } from './Dropdown'; +export * from './types'; diff --git a/src/components/Dropdown/props.ts b/src/components/Dropdown/props.ts new file mode 100644 index 000000000..73ae807d1 --- /dev/null +++ b/src/components/Dropdown/props.ts @@ -0,0 +1,69 @@ +import type { PropType } from 'vue'; +/** + * @description: 基础表格参数配置 + */ +export const dropdownProps = { + /** + * the trigger mode which executes the drop-down action + * @default ['hover'] + * @type string[] + */ + trigger: { + type: [Array] as PropType, + default: () => { + return ['contextmenu']; + }, + }, + + // /** + // * the dropdown menu + // * @type () => Menu + // */ + // overlay: { + // type: null, + // }, + + // /** + // * Class name of the dropdown root element + // * @type string + // */ + // overlayClassName: String, + + // /** + // * Style of the dropdown root element + // * @type object + // */ + // overlayStyle: Object, + + // /** + // * whether the dropdown menu is visible + // * @type boolean + // */ + // visible: Boolean, + + // /** + // * whether the dropdown menu is disabled + // * @type boolean + // */ + // disabled: Boolean, + + // /** + // * to set the ontainer of the dropdown menu. The default is to create a div element in body, you can reset it to the scrolling area and make a relative reposition. + // * @default () => document.body + // * @type Function + // */ + // getPopupContainer: Function, + + // /** + // * placement of pop menu: bottomLeft bottomCenter bottomRight topLeft topCenter topRight + // * @default 'bottomLeft' + // * @type string + // */ + // placement: String, +}; +export const basicDropdownProps = Object.assign({}, dropdownProps, { + dropMenuList: { + type: Array as PropType, + default: () => [], + }, +}); diff --git a/src/components/Dropdown/types.ts b/src/components/Dropdown/types.ts new file mode 100644 index 000000000..bf23d5ec2 --- /dev/null +++ b/src/components/Dropdown/types.ts @@ -0,0 +1,8 @@ +export interface DropMenu { + to?: string; + icon?: string; + event: string | number; + text: string; + disabled?: boolean; + divider?: boolean; +} diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts new file mode 100644 index 000000000..191b51530 --- /dev/null +++ b/src/components/Form/index.ts @@ -0,0 +1,7 @@ +export { default as BasicForm } from './src/BasicForm.vue'; + +export * from './src/types/form'; +export * from './src/types/formItem'; + +export { useComponentRegister } from './src/hooks/useComponentRegister'; +export { useForm } from './src/hooks/useForm'; diff --git a/src/components/Form/src/BasicForm.vue b/src/components/Form/src/BasicForm.vue new file mode 100644 index 000000000..fabb1036c --- /dev/null +++ b/src/components/Form/src/BasicForm.vue @@ -0,0 +1,463 @@ + + diff --git a/src/components/Form/src/FormAction.tsx b/src/components/Form/src/FormAction.tsx new file mode 100644 index 000000000..7833255fe --- /dev/null +++ b/src/components/Form/src/FormAction.tsx @@ -0,0 +1,141 @@ +import { defineComponent, unref, computed, PropType } from 'vue'; +import { Form, Col } from 'ant-design-vue'; +import type { ColEx } from './types/index'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import Button from '/@/components/Button/index.vue'; +import { UpOutlined, DownOutlined } from '@ant-design/icons-vue'; + +export default defineComponent({ + name: 'BasicFormAction', + emits: ['toggle-advanced'], + props: { + show: { + type: Boolean, + default: true, + }, + showResetButton: { + type: Boolean, + default: true, + }, + showSubmitButton: { + type: Boolean, + default: true, + }, + showAdvancedButton: { + type: Boolean, + default: true, + }, + resetButtonOptions: { + type: Object as PropType, + default: {}, + }, + submitButtonOptions: { + type: Object as PropType, + default: {}, + }, + actionColOptions: { + type: Object as PropType, + default: {}, + }, + actionSpan: { + type: Number, + default: 6, + }, + isAdvanced: { + type: Boolean, + default: false, + }, + hideAdvanceBtn: { + type: Boolean, + default: false, + }, + }, + setup(props, { slots, emit }) { + const getResetBtnOptionsRef = computed(() => { + return { + text: '重置', + ...props.resetButtonOptions, + }; + }); + const getSubmitBtnOptionsRef = computed(() => { + return { + text: '查询', + // htmlType: 'submit', + ...props.submitButtonOptions, + }; + }); + + const actionColOpt = computed(() => { + const { showAdvancedButton, actionSpan: span, actionColOptions } = props; + const actionSpan = 24 - span; + const advancedSpanObj = showAdvancedButton ? { span: actionSpan < 6 ? 24 : actionSpan } : {}; + const actionColOpt: Partial = { + span: showAdvancedButton ? 6 : 4, + ...actionColOptions, + ...advancedSpanObj, + }; + return actionColOpt; + }); + + function toggleAdvanced() { + emit('toggle-advanced'); + } + return () => { + if (!props.show) { + return; + } + const { + showAdvancedButton, + hideAdvanceBtn, + isAdvanced, + showResetButton, + showSubmitButton, + } = props; + return ( + <> + + {() => ( + + {() => ( + <> + {getSlot(slots, 'advanceBefore')} + {showAdvancedButton && !hideAdvanceBtn && ( + + )} + + {getSlot(slots, 'resetBefore')} + {showResetButton && ( + + )} + + {getSlot(slots, 'submitBefore')} + {showSubmitButton && ( + + )} + + {getSlot(slots, 'submitAfter')} + + )} + + )} + + + ); + }; + }, +}); diff --git a/src/components/Form/src/FormItem.tsx b/src/components/Form/src/FormItem.tsx new file mode 100644 index 000000000..04321e556 --- /dev/null +++ b/src/components/Form/src/FormItem.tsx @@ -0,0 +1,267 @@ +import { defineComponent, computed, unref, toRef } from 'vue'; +import { Form, Col } from 'ant-design-vue'; +import { componentMap } from './componentMap'; + +import type { PropType } from 'vue'; +import type { FormProps } from './types/form'; +import type { FormSchema } from './types/form'; +import { isBoolean, isFunction } from '/@/utils/is'; +import { useItemLabelWidth } from './hooks/useLabelWidth'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import { BasicHelp } from '/@/components/Basic'; +import { createPlaceholderMessage } from './helper'; +import { upperFirst, cloneDeep } from 'lodash-es'; +import { ValidationRule } from 'ant-design-vue/types/form/form'; +export default defineComponent({ + name: 'BasicFormItem', + inheritAttrs: false, + props: { + schema: { + type: Object as PropType, + default: () => {}, + }, + formProps: { + type: Object as PropType, + default: {}, + }, + allDefaultValues: { + type: Object as PropType, + default: {}, + }, + formModel: { + type: Object as PropType, + default: {}, + }, + }, + setup(props, { slots }) { + const itemLabelWidthRef = useItemLabelWidth(toRef(props, 'schema'), toRef(props, 'formProps')); + + const getValuesRef = computed(() => { + const { allDefaultValues, formModel, schema } = props; + const { mergeDynamicData } = props.formProps; + return { + field: schema.field, + model: formModel, + values: { + ...mergeDynamicData, + ...allDefaultValues, + ...formModel, + }, + schema: schema, + }; + }); + const getShowRef = computed(() => { + const { show, ifShow, isAdvanced } = props.schema; + const { showAdvancedButton } = props.formProps; + const itemIsAdvanced = showAdvancedButton ? !!isAdvanced : true; + let isShow = true; + let isIfShow = true; + + if (isBoolean(show)) { + isShow = show; + } + if (isBoolean(ifShow)) { + isIfShow = ifShow; + } + if (isFunction(show)) { + isShow = show(unref(getValuesRef)); + } + if (isFunction(ifShow)) { + isIfShow = ifShow(unref(getValuesRef)); + } + isShow = isShow && itemIsAdvanced; + return { isShow, isIfShow }; + }); + + const getDisableRef = computed(() => { + const { disabled: globDisabled } = props.formProps; + const { dynamicDisabled } = props.schema; + let disabled = !!globDisabled; + if (isBoolean(dynamicDisabled)) { + disabled = dynamicDisabled; + } + + if (isFunction(dynamicDisabled)) { + disabled = dynamicDisabled(unref(getValuesRef)); + } + + return disabled; + }); + + function handleRules(): ValidationRule[] { + const { + rules: defRules = [], + component, + rulesMessageJoinLabel, + label, + dynamicRules, + } = props.schema; + + if (isFunction(dynamicRules)) { + return dynamicRules(unref(getValuesRef)); + } + + const rules: ValidationRule[] = cloneDeep(defRules); + const requiredRuleIndex: number = rules.findIndex( + (rule) => Reflect.has(rule, 'required') && !Reflect.has(rule, 'validator') + ); + const { rulesMessageJoinLabel: globalRulesMessageJoinLabel } = props.formProps; + if (requiredRuleIndex !== -1) { + const rule = rules[requiredRuleIndex]; + if (rule.required && component) { + const joinLabel = Reflect.has(props.schema, 'rulesMessageJoinLabel') + ? rulesMessageJoinLabel + : globalRulesMessageJoinLabel; + rule.message = + rule.message || createPlaceholderMessage(component) + `${joinLabel ? label : ''}`; + if (component.includes('Input') || component.includes('Textarea')) { + rule.whitespace = true; + } + if ( + component.includes('DatePicker') || + component.includes('MonthPicker') || + component.includes('WeekPicker') || + component.includes('TimePicker') + ) { + rule.type = 'object'; + } + if (component.includes('RangePicker')) { + rule.type = 'array'; + } + } + } + + // 最大输入长度规则校验 + const characterInx = rules.findIndex((val) => val.max); + if (characterInx !== -1 && !rules[characterInx].validator) { + rules[characterInx].message = + rules[characterInx].message || `字符数应小于${rules[characterInx].max}位`; + } + return rules; + } + function renderComponent() { + const { + componentProps, + renderComponentContent, + component, + field, + changeEvent = 'change', + } = props.schema; + + const isCheck = component && ['Switch'].includes(component); + + const eventKey = `on${upperFirst(changeEvent)}`; + const on = { + [eventKey]: (e: any) => { + if (propsData[eventKey]) { + propsData[eventKey](e); + } + if (e && e.target) { + (props.formModel as any)[field] = e.target.value; + } else { + (props.formModel as any)[field] = e; + } + }, + }; + + const Comp = componentMap.get(component); + + const { autoSetPlaceHolder, size } = props.formProps; + const propsData: any = { + allowClear: true, + getPopupContainer: (trigger: Element) => trigger.parentNode, + size, + ...componentProps, + disabled: unref(getDisableRef), + }; + + const isCreatePlaceholder = !propsData.disabled && autoSetPlaceHolder; + let placeholder; + // RangePicker place为数组 + if (isCreatePlaceholder && component !== 'RangePicker' && component) { + placeholder = + (componentProps && componentProps.placeholder) || createPlaceholderMessage(component); + } + propsData.placeholder = placeholder; + propsData.codeField = field; + propsData.formValues = unref(getValuesRef); + + const bindValue = { + [isCheck ? 'checked' : 'value']: (props.formModel as any)[field], + }; + return ( + + {{ + ...(renderComponentContent + ? renderComponentContent(unref(getValuesRef)) + : { + default: () => '', + }), + }} + + ); + } + + function renderLabelHelpMessage() { + const { label, helpMessage, helpComponentProps } = props.schema; + if (!helpMessage || (Array.isArray(helpMessage) && helpMessage.length === 0)) { + return label; + } + return ( + + {label} + + + ); + } + function renderItem() { + const { itemProps, slot, render, field } = props.schema; + const { labelCol, wrapperCol } = unref(itemLabelWidthRef); + const { colon } = props.formProps; + const getContent = () => { + return slot + ? getSlot(slots, slot) + : render + ? render(unref(getValuesRef)) + : renderComponent(); + }; + return ( + + {() => getContent()} + + ); + } + return () => { + const { colProps = {}, colSlot, renderColContent, component } = props.schema; + if (!componentMap.has(component)) return null; + const { baseColProps = {} } = props.formProps; + + const realColProps = { ...baseColProps, ...colProps }; + + const { isIfShow, isShow } = unref(getShowRef); + + const getContent = () => { + return colSlot + ? getSlot(slots, colSlot) + : renderColContent + ? renderColContent(unref(getValuesRef)) + : renderItem(); + }; + return ( + isIfShow && ( + + {() => getContent()} + + ) + ); + }; + }, +}); diff --git a/src/components/Form/src/componentMap.ts b/src/components/Form/src/componentMap.ts new file mode 100644 index 000000000..44f67ecce --- /dev/null +++ b/src/components/Form/src/componentMap.ts @@ -0,0 +1,59 @@ +import { Component } from 'vue'; +/** + * 组件列表,在这里注册才可以在表单使用 + */ +import { + Input, + Select, + Radio, + Checkbox, + AutoComplete, + Cascader, + DatePicker, + InputNumber, + Switch, + TimePicker, + TreeSelect, + Transfer, +} from 'ant-design-vue'; + +import { ComponentType } from './types/index'; + +const componentMap = new Map(); + +componentMap.set('Input', Input); +componentMap.set('InputGroup', Input.Group); +componentMap.set('InputPassword', Input.Password); +componentMap.set('InputSearch', Input.Search); +componentMap.set('InputTextArea', Input.TextArea); +componentMap.set('InputNumber', InputNumber); +componentMap.set('AutoComplete', AutoComplete); + +componentMap.set('Select', Select); +componentMap.set('SelectOptGroup', Select.OptGroup); +componentMap.set('SelectOption', Select.Option); +componentMap.set('TreeSelect', TreeSelect); +componentMap.set('Transfer', Transfer); +componentMap.set('Radio', Radio); +componentMap.set('Switch', Switch); +componentMap.set('RadioButton', Radio.Button); +componentMap.set('RadioGroup', Radio.Group); +componentMap.set('Checkbox', Checkbox); +componentMap.set('CheckboxGroup', Checkbox.Group); +componentMap.set('Cascader', Cascader); + +componentMap.set('DatePicker', DatePicker); +componentMap.set('MonthPicker', DatePicker.MonthPicker); +componentMap.set('RangePicker', DatePicker.RangePicker); +componentMap.set('WeekPicker', DatePicker.WeekPicker); +componentMap.set('TimePicker', TimePicker); + +export function add(compName: ComponentType, component: Component) { + componentMap.set(compName, component); +} + +export function del(compName: ComponentType) { + componentMap.delete(compName); +} + +export { componentMap }; diff --git a/src/components/Form/src/helper.ts b/src/components/Form/src/helper.ts new file mode 100644 index 000000000..174775eb6 --- /dev/null +++ b/src/components/Form/src/helper.ts @@ -0,0 +1,30 @@ +import { ComponentType } from './types/index'; +/** + * @description: 生成placeholder + */ +export function createPlaceholderMessage(component: ComponentType) { + if (component.includes('Input') || component.includes('Complete')) { + return '请输入'; + } + if (component.includes('Picker') && !component.includes('Range')) { + return '请选择'; + } + if ( + component.includes('Select') || + component.includes('Cascader') || + component.includes('Checkbox') || + component.includes('Radio') || + component.includes('Switch') + ) { + // return `请选择${label}`; + return '请选择'; + } + return ''; +} +function genType() { + return ['DatePicker', 'MonthPicker', 'RangePicker', 'WeekPicker', 'TimePicker']; +} +/** + * 时间字段 + */ +export const dateItemType = genType(); diff --git a/src/components/Form/src/hooks/useComponentRegister.ts b/src/components/Form/src/hooks/useComponentRegister.ts new file mode 100644 index 000000000..19fe5b7a2 --- /dev/null +++ b/src/components/Form/src/hooks/useComponentRegister.ts @@ -0,0 +1,10 @@ +import { tryOnUnmounted } from '/@/utils/helper/vueHelper'; +import { add, del } from '../componentMap'; + +import { ComponentType } from '../types/index'; +export function useComponentRegister(compName: ComponentType, comp: any) { + add(compName, comp); + tryOnUnmounted(() => { + del(compName); + }); +} diff --git a/src/components/Form/src/hooks/useForm.ts b/src/components/Form/src/hooks/useForm.ts new file mode 100644 index 000000000..b89c2ebc0 --- /dev/null +++ b/src/components/Form/src/hooks/useForm.ts @@ -0,0 +1,69 @@ +import { ref, onUnmounted, unref } from 'vue'; + +import { isInSetup } from '/@/utils/helper/vueHelper'; + +import type { FormProps, FormActionType, UseFormReturnType, FormSchema } from '../types/form'; +import { isProdMode } from '/@/utils/env'; +import type { NamePath } from 'ant-design-vue/types/form/form-item'; +import type { ValidateFields } from 'ant-design-vue/types/form/form'; + +export function useForm(props?: Partial): UseFormReturnType { + isInSetup(); + const formRef = ref(null); + const loadedRef = ref(false); + function getForm() { + const form = unref(formRef); + if (!form) { + throw new Error('formRef is Null'); + } + return form as FormActionType; + } + function register(instance: FormActionType) { + isProdMode() && + onUnmounted(() => { + formRef.value = null; + loadedRef.value = null; + }); + if (unref(loadedRef) && isProdMode() && instance === unref(formRef)) return; + formRef.value = instance; + props && instance.setProps(props); + loadedRef.value = true; + } + + const methods: FormActionType = { + setProps: (formProps: Partial) => { + getForm().setProps(formProps); + }, + updateSchema: (data: Partial | Partial[]) => { + getForm().updateSchema(data); + }, + clearValidate: (name?: string | string[]) => { + getForm().clearValidate(name); + }, + resetFields: async () => { + await getForm().resetFields(); + }, + removeSchemaByFiled: (field: string | string[]) => { + getForm().removeSchemaByFiled(field); + }, + getFieldsValue: () => { + return getForm().getFieldsValue(); + }, + setFieldsValue: (values: T) => { + getForm().setFieldsValue(values); + }, + appendSchemaByField: (schema: FormSchema, prefixField?: string | undefined) => { + getForm().appendSchemaByField(schema, prefixField); + }, + submit: async (): Promise => { + return getForm().submit(); + }, + validate: ((async (nameList?: NamePath[]): Promise => { + return getForm().validate(nameList); + }) as any) as ValidateFields, + validateFields: ((async (nameList?: NamePath[]): Promise => { + return getForm().validate(nameList); + }) as any) as ValidateFields, + } as FormActionType; + return [register, methods]; +} diff --git a/src/components/Form/src/hooks/useFormValues.ts b/src/components/Form/src/hooks/useFormValues.ts new file mode 100644 index 000000000..0367e3e8d --- /dev/null +++ b/src/components/Form/src/hooks/useFormValues.ts @@ -0,0 +1,62 @@ +import { isArray, isFunction, isObject, isString } from '/@/utils/is'; +import moment from 'moment'; +import { unref } from 'vue'; +import type { Ref } from 'vue'; +import type { FieldMapToTime } from '../types/form'; + +export function useFormValues( + transformDateFuncRef: Ref, + fieldMapToTimeRef: Ref +) { + // 处理表单值 + function handleFormValues(values: any) { + if (!isObject(values)) { + return {}; + } + const resMap: any = {}; + for (const item of Object.entries(values)) { + let [, value] = item; + const [key] = item; + if ((isArray(value) && value.length === 0) || isFunction(value)) { + continue; + } + const transformDateFunc = unref(transformDateFuncRef); + if (isObject(value)) { + value = transformDateFunc(value); + } + if (isArray(value) && value[0]._isAMomentObject && value[1]._isAMomentObject) { + value = value.map((item) => transformDateFunc(item)); + } + // 去除空格 + if (isString(value)) { + value = value.trim(); + } + resMap[key] = value; + } + return handleRangeTimeValue(resMap); + } + /** + * @description: 处理时间区间参数 + */ + function handleRangeTimeValue(values: any) { + const fieldMapToTime = unref(fieldMapToTimeRef); + + if (!fieldMapToTime || !Array.isArray(fieldMapToTime)) { + return values; + } + + for (const [field, [startTimeKey, endTimeKey, format = 'YYYY-MM-DD']] of fieldMapToTime) { + if (!field || !startTimeKey || !endTimeKey || !values[field]) { + continue; + } + + const [startTime, endTime]: string[] = values[field]; + + values[startTimeKey] = moment(startTime).format(format); + values[endTimeKey] = moment(endTime).format(format); + } + + return values; + } + return handleFormValues; +} diff --git a/src/components/Form/src/hooks/useLabelWidth.ts b/src/components/Form/src/hooks/useLabelWidth.ts new file mode 100644 index 000000000..d32d238f5 --- /dev/null +++ b/src/components/Form/src/hooks/useLabelWidth.ts @@ -0,0 +1,43 @@ +import type { Ref } from 'vue'; +import type { FormProps, FormSchema } from '../types/form'; + +import { computed, unref } from 'vue'; +import { isNumber } from '/@/utils/is'; + +// export function useGlobalLabelWidth(propsRef: ComputedRef) { +// return computed(() => { +// const { labelWidth, labelCol, wrapperCol } = unref(propsRef); +// if (!labelWidth) { +// return { labelCol, wrapperCol }; +// } + +// const width = isNumber(labelWidth) ? `${labelWidth}px` : labelWidth; +// return { +// labelCol: { style: { width }, span: 1, ...labelCol }, +// wrapperCol: { style: { width: `calc(100% - ${width})` }, span: 23, ...wrapperCol }, +// }; +// }); +// } + +export function useItemLabelWidth(schemaItemRef: Ref, propsRef: Ref) { + return computed((): any => { + const schemaItem = unref(schemaItemRef); + const { labelCol = {}, wrapperCol = {} } = schemaItem.itemProps || {}; + const { labelWidth, disabledLabelWidth } = schemaItem; + + const { labelWidth: globalLabelWidth } = unref(propsRef) as any; + // 如果全局有设置labelWidth, 则所有item使用 + if ((!globalLabelWidth && !labelWidth) || disabledLabelWidth) { + return { labelCol, wrapperCol }; + } + let width = labelWidth || globalLabelWidth; + + if (width) { + width = isNumber(width) ? `${width}px` : width; + } + return { + labelCol: { style: { width }, span: 1, ...labelCol }, + wrapperCol: { style: { width: `calc(100% - ${width})` }, span: 23, ...wrapperCol }, + }; + }); +} diff --git a/src/components/Form/src/props.ts b/src/components/Form/src/props.ts new file mode 100644 index 000000000..fbcb5f2bd --- /dev/null +++ b/src/components/Form/src/props.ts @@ -0,0 +1,107 @@ +import type { FieldMapToTime, FormSchema } from './types/form'; +import type { PropType } from 'vue'; +import type { ColEx } from './types'; + +export const basicProps = { + // 标签宽度 固定宽度 + labelWidth: { + type: [Number, String] as PropType, + default: 0, + }, + fieldMapToTime: { + type: Array as PropType, + default: () => [], + }, + compact: Boolean as PropType, + // 表单配置规则 + schemas: { + type: [Array] as PropType, + default: () => [], + required: true, + }, + mergeDynamicData: { + type: Object as PropType, + default: null, + }, + baseColProps: { + type: Object as PropType, + }, + autoSetPlaceHolder: { + type: Boolean, + default: true, + }, + size: { + type: String as PropType<'default' | 'small' | 'large'>, + default: 'default', + }, + // 禁用表单 + disabled: Boolean as PropType, + emptySpan: { + type: [Number, Object] as PropType, + default: 0, + }, + // 是否显示收起展开按钮 + showAdvancedButton: { type: Boolean as PropType, default: false }, + // 转化时间 + transformDateFunc: { + type: Function as PropType, + default: (date: any) => { + return date._isAMomentObject ? date.format('YYYY-MM-DD HH:mm:ss') : date; + }, + }, + rulesMessageJoinLabel: { + type: Boolean, + default: true, + }, + // 超过3行自动折叠 + autoAdvancedLine: { + type: Number as PropType, + default: 3, + }, + + // 是否显示操作按钮 + showActionButtonGroup: { + type: Boolean as PropType, + default: true, + }, + // 操作列Col配置 + actionColOptions: Object as PropType, + // 显示重置按钮 + showResetButton: { + type: Boolean as PropType, + default: true, + }, + // 重置按钮配置 + resetButtonOptions: Object as PropType, + + // 显示确认按钮 + showSubmitButton: { + type: Boolean as PropType, + default: true, + }, + // 确认按钮配置 + submitButtonOptions: Object as PropType, + + // 自定义重置函数 + resetFunc: Function as PropType, + submitFunc: Function as PropType, + + // 以下为默认props + hideRequiredMark: Boolean as PropType, + + labelCol: Object as PropType, + + layout: { + type: String as PropType<'horizontal' | 'vertical' | 'inline'>, + default: 'horizontal', + }, + + wrapperCol: Object as PropType, + + colon: { + type: Boolean as PropType, + default: false, + }, + + labelAlign: String as PropType, +}; diff --git a/src/components/Form/src/types/form.ts b/src/components/Form/src/types/form.ts new file mode 100644 index 000000000..d32654611 --- /dev/null +++ b/src/components/Form/src/types/form.ts @@ -0,0 +1,159 @@ +import type { Form, ValidationRule } from 'ant-design-vue/types/form/form'; +import type { VNode } from 'vue'; +import type { BasicButtonProps } from '/@/components/Button/types'; +import type { FormItem } from './formItem'; +import type { ColEx, ComponentType } from './index'; + +export type FieldMapToTime = [string, [string, string], string?][]; + +export interface RenderCallbackParams { + schema: FormSchema; + values: any; + model: any; + field: string; +} +export interface FormActionType extends Form { + submit(): Promise; + setFieldsValue(values: T): void; + resetFields(): Promise; + getFieldsValue: () => any; + clearValidate: (name?: string | string[]) => void; + updateSchema(data: Partial | Partial[]): void; + setProps(formProps: Partial): void; + removeSchemaByFiled(field: string | string[]): void; + appendSchemaByField(schema: FormSchema, prefixField?: string): void; +} +export type RegisterFn = (formInstance: FormActionType) => void; + +export type UseFormReturnType = [RegisterFn, FormActionType]; + +export interface FormProps { + // 整个表单所有项宽度 + labelWidth?: number | string; + + // 整个表单通用Col配置 + labelCol?: Partial; + // 整个表单通用Col配置 + wrapperCol?: Partial; + + // 通用col配置 + baseColProps?: any; + + // 表单配置规则 + schemas?: FormSchema[]; + // 用于合并到动态控制表单项的 函数values + mergeDynamicData?: any; + // 紧凑模式,用于搜索表单 + compact?: boolean; + // 空白行span + emptySpan?: number | Partial; + // 表单内部组件大小 + size: 'default' | 'small' | 'large'; + // 是否禁用 + disabled?: boolean; + // 时间区间字段映射成多个 + fieldMapToTime?: FieldMapToTime; + // 自动设置placeholder + autoSetPlaceHolder: boolean; + // 校验信息是否加入label + rulesMessageJoinLabel?: boolean; + // 是否显示收起展开按钮 + showAdvancedButton?: boolean; + // 超过指定行数自动收起 + autoAdvancedLine?: number; + // 是否显示操作按钮 + showActionButtonGroup: boolean; + + // 重置按钮配置 + resetButtonOptions: Partial; + + // 确认按钮配置 + submitButtonOptions: Partial; + + // 操作列配置 + actionColOptions: Partial; + + // 显示重置按钮 + showResetButton: boolean; + // 显示确认按钮 + showSubmitButton: boolean; + + resetFunc: () => Promise; + submitFunc: () => Promise; + transformDateFunc: (date: any) => string; + colon?: boolean; +} +export interface FormSchema { + // 字段名 + field: string; + changeEvent?: string; + // 标签名 + label: string; + // 文本右侧帮助文本 + helpMessage?: string | string[]; + // BaseHelp组件props + helpComponentProps?: Partial; + // label宽度,有传的话 itemProps配置的 labelCol 和WrapperCol会失效 + labelWidth?: string | number; + // 禁用调有formModel全局设置的labelWidth,自己手动设置 labelCol和wrapperCol + disabledLabelWidth?: boolean; + // 组件 + component: ComponentType; + // 组件参数 + componentProps?: any; + + // 校验规则 + rules?: ValidationRule[]; + // 校验信息是否加入label + rulesMessageJoinLabel?: boolean; + + // 参考formModelItem + itemProps?: Partial; + + // formModelItem外层的col配置 + colProps?: Partial; + + // 默认值 + defaultValue?: any; + isAdvanced?: boolean; + + // 配合详情组件 + span?: number; + + ifShow?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean); + + show?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean); + + // 渲染form-item标签内的内容 + render?: (renderCallbackParams: RenderCallbackParams) => VNode | VNode[] | string; + + // 渲染 col内容,需要外层包裹 form-item + renderColContent?: (renderCallbackParams: RenderCallbackParams) => VNode | VNode[]; + + renderComponentContent?: (renderCallbackParams: RenderCallbackParams) => any; + + // 自定义slot, 在 from-item内 + slot?: string; + + // 自定义slot,类似renderColContent + colSlot?: string; + + dynamicDisabled?: boolean | ((renderCallbackParams: RenderCallbackParams) => boolean); + + dynamicRules?: (renderCallbackParams: RenderCallbackParams) => ValidationRule[]; +} +export interface HelpComponentProps { + maxWidth: string; + // 是否显示序号 + showIndex: boolean; + // 文本列表 + text: any; + // 颜色 + color: string; + // 字体大小 + fontSize: string; + icon: string; + absolute: boolean; + // 定位 + position: any; +} diff --git a/src/components/Form/src/types/formItem.ts b/src/components/Form/src/types/formItem.ts new file mode 100644 index 000000000..f573d92b1 --- /dev/null +++ b/src/components/Form/src/types/formItem.ts @@ -0,0 +1,91 @@ +import type { NamePath } from 'ant-design-vue/types/form/form-item'; +import type { Col } from 'ant-design-vue/types/grid/col'; +import type { VNodeChild } from 'vue'; + +export interface FormItem { + /** + * Used with label, whether to display : after label text. + * @default true + * @type boolean + */ + colon?: boolean; + + /** + * The extra prompt message. It is similar to help. Usage example: to display error message and prompt message at the same time. + * @type any (string | slot) + */ + extra?: string | VNodeChild | JSX.Element; + + /** + * Used with validateStatus, this option specifies the validation status icon. Recommended to be used only with Input. + * @default false + * @type boolean + */ + hasFeedback?: boolean; + + /** + * The prompt message. If not provided, the prompt message will be generated by the validation rule. + * @type any (string | slot) + */ + help?: string | VNodeChild | JSX.Element; + + /** + * Label test + * @type any (string | slot) + */ + label?: string | VNodeChild | JSX.Element; + + /** + * The layout of label. You can set span offset to something like {span: 3, offset: 12} or sm: {span: 3, offset: 12} same as with + * @type Col + */ + labelCol?: Col; + + /** + * Whether provided or not, it will be generated by the validation rule. + * @default false + * @type boolean + */ + required?: boolean; + + /** + * The validation status. If not provided, it will be generated by validation rule. options: 'success' 'warning' 'error' 'validating' + * @type string + */ + validateStatus?: '' | 'success' | 'warning' | 'error' | 'validating'; + + /** + * The layout for input controls, same as labelCol + * @type Col + */ + wrapperCol?: Col; + /** + * Set sub label htmlFor. + */ + htmlFor?: string; + /** + * text align of label + */ + labelAlign?: 'left' | 'right'; + /** + * a key of model. In the use of validate and resetFields method, the attribute is required + */ + name?: NamePath; + /** + * validation rules of form + */ + rules?: object | object[]; + /** + * Whether to automatically associate form fields. In most cases, you can use automatic association. + * If the conditions for automatic association are not met, you can manually associate them. See the notes below. + */ + autoLink?: boolean; + /** + * Whether stop validate on first rule of error for this field. + */ + validateFirst?: boolean; + /** + * When to validate the value of children node + */ + validateTrigger?: string | string[] | false; +} diff --git a/src/components/Form/src/types/index.ts b/src/components/Form/src/types/index.ts new file mode 100644 index 000000000..a38b12e53 --- /dev/null +++ b/src/components/Form/src/types/index.ts @@ -0,0 +1,113 @@ +import { ColSpanType } from 'ant-design-vue/types/grid/col'; + +export interface ColEx { + style: object; + /** + * raster number of cells to occupy, 0 corresponds to display: none + * @default none (0) + * @type ColSpanType + */ + span?: ColSpanType; + + /** + * raster order, used in flex layout mode + * @default 0 + * @type ColSpanType + */ + order?: ColSpanType; + + /** + * the layout fill of flex + * @default none + * @type ColSpanType + */ + flex?: ColSpanType; + + /** + * the number of cells to offset Col from the left + * @default 0 + * @type ColSpanType + */ + offset?: ColSpanType; + + /** + * the number of cells that raster is moved to the right + * @default 0 + * @type ColSpanType + */ + push?: ColSpanType; + + /** + * the number of cells that raster is moved to the left + * @default 0 + * @type ColSpanType + */ + pull?: ColSpanType; + + /** + * <576px and also default setting, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + xs?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥576px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + sm?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥768px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + md?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥992px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + lg?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥1200px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + xl?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; + + /** + * ≥1600px, could be a span value or an object containing above props + * @type { span: ColSpanType, offset: ColSpanType } | ColSpanType + */ + xxl?: { span: ColSpanType; offset: ColSpanType } | ColSpanType; +} + +export type ComponentType = + | 'Input' + | 'InputGroup' + | 'InputPassword' + | 'InputSearch' + | 'InputTextArea' + | 'InputNumber' + | 'InputCountDown' + | 'Select' + | 'DictSelect' + | 'SelectOptGroup' + | 'SelectOption' + | 'TreeSelect' + | 'Transfer' + | 'Radio' + | 'RadioButton' + | 'RadioGroup' + | 'Checkbox' + | 'CheckboxGroup' + | 'AutoComplete' + | 'Cascader' + | 'DatePicker' + | 'MonthPicker' + | 'RangePicker' + | 'WeekPicker' + | 'TimePicker' + | 'ImageUpload' + | 'Switch' + | 'StrengthMeter' + | 'Render'; diff --git a/src/components/Icon/README.md b/src/components/Icon/README.md new file mode 100644 index 000000000..79260d3f1 --- /dev/null +++ b/src/components/Icon/README.md @@ -0,0 +1,7 @@ +### `Icon.vue` + +```html + +``` + +The icon id follows the rules in [Iconify](https://iconify.design/) which you can use any icons from the supported icon sets. diff --git a/src/components/Icon/index.less b/src/components/Icon/index.less new file mode 100644 index 000000000..9e443e3a4 --- /dev/null +++ b/src/components/Icon/index.less @@ -0,0 +1,14 @@ +@import (reference) '../../design/index.less'; + +.app-iconify { + display: inline-block; + vertical-align: middle; +} + +span.iconify { + display: block; + min-width: 1em; + min-height: 1em; + background: @iconify-bg-color; + border-radius: 100%; +} diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx new file mode 100644 index 000000000..ce64299fc --- /dev/null +++ b/src/components/Icon/index.tsx @@ -0,0 +1,76 @@ +import type { PropType } from 'vue'; +import { defineComponent, ref, watch, onMounted, nextTick, unref, computed } from 'vue'; +import Iconify from '@purge-icons/generated'; +import { isString } from '/@/utils/is'; +import './index.less'; +export default defineComponent({ + name: 'GIcon', + props: { + // icon name + icon: { + type: String as PropType, + required: true, + }, + // icon color + color: { + type: String as PropType, + }, + // icon size + size: { + type: [String, Number] as PropType, + default: 14, + }, + prefix: { + type: String as PropType, + default: '', + }, + }, + setup(props, { attrs }) { + const elRef = ref>(null); + + const getIconRef = computed(() => { + const { icon, prefix } = props; + return `${prefix ? prefix + ':' : ''}${icon}`; + }); + const update = async () => { + const el = unref(elRef); + if (el) { + await nextTick(); + const icon = unref(getIconRef); + + const svg = Iconify.renderSVG(icon, {}); + + if (svg) { + el.textContent = ''; + el.appendChild(svg); + } else { + const span = document.createElement('span'); + span.className = 'iconify'; + span.dataset.icon = icon; + el.textContent = ''; + el.appendChild(span); + } + } + }; + + watch(() => props.icon, update, { flush: 'post' }); + + const wrapStyleRef = computed((): any => { + const { size, color } = props; + let fs = size; + if (isString(size)) { + fs = parseInt(size, 10); + } + return { + fontSize: `${fs}px`, + color, + }; + }); + + onMounted(update); + + return () => ( +
    + ); + }, +}); diff --git a/src/components/Loading/BasicLoading.vue b/src/components/Loading/BasicLoading.vue new file mode 100644 index 000000000..8736aaceb --- /dev/null +++ b/src/components/Loading/BasicLoading.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/components/Loading/FullLoading.vue b/src/components/Loading/FullLoading.vue new file mode 100644 index 000000000..e06a6489a --- /dev/null +++ b/src/components/Loading/FullLoading.vue @@ -0,0 +1,41 @@ + + diff --git a/src/components/Loading/index.ts b/src/components/Loading/index.ts new file mode 100644 index 000000000..39bdd88a3 --- /dev/null +++ b/src/components/Loading/index.ts @@ -0,0 +1,2 @@ +export { default as BasicLoading } from './BasicLoading.vue'; +export { default as FullLoading } from './FullLoading.vue'; diff --git a/src/components/Loading/type.ts b/src/components/Loading/type.ts new file mode 100644 index 000000000..03a050fe0 --- /dev/null +++ b/src/components/Loading/type.ts @@ -0,0 +1,8 @@ +import { SizeEnum } from '/@/enums/sizeEnum'; + +export interface BasicLoadingProps { + // 提示语 + tip: string; + + size: SizeEnum; +} diff --git a/src/components/Menu/index.ts b/src/components/Menu/index.ts new file mode 100644 index 000000000..5471c6845 --- /dev/null +++ b/src/components/Menu/index.ts @@ -0,0 +1 @@ +export { default as BasicMenu } from './src/BasicMenu'; diff --git a/src/components/Menu/src/BasicMenu.tsx b/src/components/Menu/src/BasicMenu.tsx new file mode 100644 index 000000000..ed36e68ad --- /dev/null +++ b/src/components/Menu/src/BasicMenu.tsx @@ -0,0 +1,260 @@ +import type { MenuState } from './types'; +import type { Menu as MenuType } from '/@/router/types'; + +import { + computed, + defineComponent, + unref, + reactive, + toRef, + watch, + onMounted, + watchEffect, + ref, +} from 'vue'; +import { basicProps } from './props'; +import { Menu } from 'ant-design-vue'; +import { MenuModeEnum, MenuTypeEnum } from '/@/enums/menuEnum'; +import { menuStore } from '/@/store/modules/menu'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import { ScrollContainer } from '/@/components/Container/index'; +import SearchInput from './SearchInput.vue'; +import './index.less'; +import { menuHasChildren } from './helper'; +import MenuContent from './MenuContent'; +import { useSearchInput } from './useSearchInput'; +import { useOpenKeys } from './useOpenKeys'; +import { useRouter } from 'vue-router'; +import { isFunction } from '/@/utils/is'; +import { getCurrentParentPath } from '/@/router/menus'; +export default defineComponent({ + name: 'BasicMenu', + props: basicProps, + emits: ['menuClick'], + setup(props, { slots, emit }) { + const currentParentPath = ref(''); + const menuState = reactive({ + defaultSelectedKeys: [], + mode: props.mode, + theme: computed(() => props.theme), + openKeys: [], + searchValue: '', + selectedKeys: [], + collapsedOpenKeys: [], + }); + const { currentRoute } = useRouter(); + + const { handleInputChange, handleInputClick } = useSearchInput({ + flatMenusRef: toRef(props, 'flatItems'), + emit: emit, + menuState, + handleMenuChange, + }); + + const { handleOpenChange, resetKeys, setOpenKeys } = useOpenKeys( + menuState, + toRef(props, 'items'), + toRef(props, 'flatItems'), + toRef(props, 'isAppMenu') + ); + + const getOpenKeys = computed(() => { + if (props.isAppMenu) { + return menuStore.getCollapsedState ? menuState.collapsedOpenKeys : menuState.openKeys; + } + return menuState.openKeys; + }); + // menu外层样式 + const getMenuWrapStyle = computed((): any => { + const { showLogo, search } = props; + let offset = 0; + if (search) { + offset += 60; + } + if (showLogo) { + offset += 54; + } + return { + height: `calc(100% - ${offset}px)`, + position: 'relative', + }; + }); + + // 是否透明化左侧一级菜单 + const transparentMenuClass = computed(() => { + const { type } = props; + const { mode } = menuState; + if ( + [MenuTypeEnum.MIX, MenuTypeEnum.SIDEBAR].includes(type) && + mode !== MenuModeEnum.HORIZONTAL + ) { + return `basic-menu-bg__sidebar`; + } + if ( + (type === MenuTypeEnum.TOP_MENU && mode === MenuModeEnum.HORIZONTAL) || + props.appendClass + ) { + return `basic-menu-bg__sidebar-hor`; + } + return ''; + }); + + watch( + () => currentRoute.value.name, + (name: string) => { + name !== 'Redirect' && handleMenuChange(); + getParentPath(); + } + ); + watchEffect(() => { + if (props.items) { + handleMenuChange(); + } + }); + + async function getParentPath() { + const { appendClass } = props; + if (!appendClass) return ''; + const parentPath = await getCurrentParentPath(unref(currentRoute).path); + currentParentPath.value = parentPath; + } + + async function handleMenuClick(menu: MenuType) { + const { beforeClickFn } = props; + if (beforeClickFn && isFunction(beforeClickFn)) { + const flag = await beforeClickFn(menu); + if (!flag) { + return; + } + } + const { path } = menu; + menuState.selectedKeys = [path]; + emit('menuClick', menu); + } + function handleMenuChange() { + const { flatItems } = props; + if (!unref(flatItems) || flatItems.length === 0) { + return; + } + const findMenu = flatItems.find((menu) => menu.path === unref(currentRoute).path); + if (findMenu) { + if (menuState.mode !== MenuModeEnum.HORIZONTAL) { + setOpenKeys(findMenu); + } + menuState.selectedKeys = [findMenu.path]; + } else { + resetKeys(); + } + } + // render menu item + function renderMenuItem(menuList?: MenuType[], index = 1) { + if (!menuList) { + return; + } + const { appendClass } = props; + const levelCls = `basic-menu-item__level${index} ${menuState.theme} `; + + const showTitle = props.isAppMenu ? !menuStore.getCollapsedState : true; + return menuList.map((menu) => { + if (!menu) { + return null; + } + + const isAppendActiveCls = + appendClass && index === 1 && menu.path === unref(currentParentPath); + // 没有子节点 + if (!menuHasChildren(menu)) { + return ( + + {() => [ + , + ]} + + ); + } + return ( + + {{ + title: () => [ + , + ], + default: () => renderMenuItem(menu.children, index + 1), + }} + + ); + }); + } + + function renderMenu() { + const isInline = props.mode === MenuModeEnum.INLINE; + const { selectedKeys, defaultSelectedKeys, mode, theme } = menuState; + + const inlineCollapsedObj = isInline + ? props.isAppMenu + ? { + inlineCollapsed: menuStore.getCollapsedState, + } + : { inlineCollapsed: props.inlineCollapsed } + : {}; + return ( + + {{ + default: () => renderMenuItem(props.items, 1), + }} + + ); + } + + onMounted(async () => { + getParentPath(); + }); + return () => { + const { getCollapsedState } = menuStore; + const { mode } = props; + + return mode === MenuModeEnum.HORIZONTAL ? ( + renderMenu() + ) : ( +
    + {getSlot(slots, 'header')} + {props.search && ( + + )} +
    + {() => renderMenu()} +
    +
    + ); + }; + }, +}); diff --git a/src/components/Menu/src/MenuContent.tsx b/src/components/Menu/src/MenuContent.tsx new file mode 100644 index 000000000..6e0c93f45 --- /dev/null +++ b/src/components/Menu/src/MenuContent.tsx @@ -0,0 +1,61 @@ +import type { Menu as MenuType } from '/@/router/types'; +import type { PropType } from 'vue'; + +import { defineComponent } from 'vue'; +import Icon from '/@/components/Icon/index'; + +export default defineComponent({ + name: 'MenuContent', + props: { + searchValue: { + type: String as PropType, + default: '', + }, + item: { + type: Object as PropType, + default: null, + }, + showTitle: { + type: Boolean as PropType, + default: true, + }, + level: { + type: Number as PropType, + default: 0, + }, + }, + setup(props) { + /** + * @description: 渲染图标 + */ + function renderIcon(icon: string) { + return icon ? : null; + } + + return () => { + if (!props.item) { + return null; + } + const { showTitle, level } = props; + const { name, icon } = props.item; + const searchValue = props.searchValue || ''; + const index = name.indexOf(searchValue); + + const beforeStr = name.substr(0, index); + const afterStr = name.substr(index + searchValue.length); + const show = level === 1 ? showTitle : true; + return [ + renderIcon(icon!), + index > -1 && searchValue ? ( + + {beforeStr} + {searchValue} + {afterStr} + + ) : ( + {name} + ), + ]; + }; + }, +}); diff --git a/src/components/Menu/src/SearchInput.vue b/src/components/Menu/src/SearchInput.vue new file mode 100644 index 000000000..43dec809a --- /dev/null +++ b/src/components/Menu/src/SearchInput.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/components/Menu/src/helper.ts b/src/components/Menu/src/helper.ts new file mode 100644 index 000000000..51f069432 --- /dev/null +++ b/src/components/Menu/src/helper.ts @@ -0,0 +1,12 @@ +import type { Menu as MenuType } from '/@/router/types'; + +/** + * @description: Whether the menu has child nodes + */ +export function menuHasChildren(menuTreeItem: MenuType): boolean { + return ( + Reflect.has(menuTreeItem, 'children') && + !!menuTreeItem.children && + menuTreeItem.children.length > 0 + ); +} diff --git a/src/components/Menu/src/index.less b/src/components/Menu/src/index.less new file mode 100644 index 000000000..7847fe0be --- /dev/null +++ b/src/components/Menu/src/index.less @@ -0,0 +1,246 @@ +@import (reference) '../../../design/index.less'; + +.active-menu-style() { + .ant-menu-item-selected, + .ant-menu-submenu-popup.ant-menu-dark .ant-menu-item-selected { + background: linear-gradient( + 118deg, + rgba(@primary-color, 0.7), + rgba(@primary-color, 1) + ) !important; + border-radius: 2px; + box-shadow: 0 0 4px 1px rgba(@primary-color, 0.7); + } +} + +.basic-menu { + &-wrap { + height: 100%; + } + + .menu-item-icon { + vertical-align: text-top; + } + // 透明化背景 + &-bg__sidebar { + background-color: transparent; + } + + &-bg__sidebar-hor { + &.ant-menu-horizontal { + display: flex; + border: 0; + align-items: center; + + .basic-menu-item__level1 { + margin-right: 2px; + } + + &.ant-menu-light { + .ant-menu-item { + &.basic-menu-item__level1 { + height: 46px; + line-height: 46px; + } + } + + .ant-menu-item:hover, + .ant-menu-submenu:hover, + .ant-menu-item-active, + .ant-menu-submenu-active, + .ant-menu-item-open, + .ant-menu-submenu-open, + .ant-menu-item-selected, + .ant-menu-submenu-selected { + color: @primary-color; + border-bottom: 3px solid @primary-color; + } + + .ant-menu-item:hover, + .ant-menu-item-active, + .ant-menu:not(.ant-menu-inline) .ant-menu-submenu-open, + .ant-menu-submenu-active, + .ant-menu-submenu-title:hover { + color: @primary-color; + border-bottom: 3px solid @primary-color; + } + + // 有子菜单 + .ant-menu-submenu { + &:hover { + border-bottom: 3px solid @primary-color; + } + + &.ant-menu-selected, + &.ant-menu-submenu-selected { + border-bottom: 3px solid @primary-color; + } + } + } + + &.ant-menu-dark { + background: transparent; + + .ant-menu-item:hover, + .ant-menu-item-active, + .ant-menu:not(.ant-menu-inline) .ant-menu-submenu-open, + .ant-menu-submenu-active, + .ant-menu-submenu-title:hover { + background: @top-menu-active-bg-color; + border-radius: 6px 6px 0 0; + } + + .basic-menu-item__level1 { + &.ant-menu-item-selected, + &.ant-menu-submenu-selected { + background: @top-menu-active-bg-color; + border-radius: 6px 6px 0 0; + } + } + + .ant-menu-item { + &.basic-menu-item__level1 { + height: @header-height; + line-height: @header-height; + } + } + // 有子菜单 + .ant-menu-submenu { + &.basic-menu-item__level1, + .ant-menu-submenu-title { + height: @header-height; + line-height: @header-height; + } + } + } + } + } + // 重置菜单项行高 + .ant-menu-item, + .ant-menu-sub.ant-menu-inline > .ant-menu-item, + .ant-menu-sub.ant-menu-inline > .ant-menu-submenu > .ant-menu-submenu-title { + height: @app-menu-item-height; + margin: 0; + line-height: @app-menu-item-height; + } + + &.ant-menu-dark:not(.basic-menu-bg__sidebar-hor) { + .active-menu-style(); + } + + // 层级样式 + &.ant-menu-dark { + .ant-menu-item { + transition: unset; + } + + .ant-menu-item.ant-menu-item-selected.basic-menu-menu-item__level1, + .ant-menu-submenu-selected.basic-menu-menu-item__level1 { + color: @white; + } + + .basic-menu-item__level1 { + margin-bottom: 0; + + &.top-active-menu { + color: @white; + background: @top-menu-active-bg-color; + border-radius: 6px 6px 0 0; + } + } + + // 2级菜单 + + .basic-menu-item__level2:not(.ant-menu-item-selected) { + background-color: @sub-menu-item-dark-bg-color; + } + + .basic-menu-item__level2 { + margin-bottom: 0; + } + // 3级菜单 + .basic-menu-item__level3, + .basic-menu__popup { + margin-bottom: 0; + } + + .basic-menu-item__level3:not(.ant-menu-item-selected) { + background-color: @children-menu-item-dark-bg-color; + } + + .ant-menu-submenu-title { + height: @app-menu-item-height; + margin: 0; + line-height: @app-menu-item-height; + } + } + + &.ant-menu-light { + .basic-menu-item__level1 { + &.top-active-menu { + color: @primary-color; + border-bottom: 6px solid @primary-color; + } + } + + &:not(.ant-menu-horizontal) { + .ant-menu-item-selected { + background: fade(@primary-color, 18%); + } + } + + .ant-menu-item.ant-menu-item-selected.basic-menu-menu-item__level1, + .ant-menu-submenu-selected.basic-menu-menu-item__level1 { + color: @primary-color; + } + } + + // 关键字的颜色 + &__keyword { + color: lighten(@primary-color, 20%); + } + + // 激活的子菜单样式 + .ant-menu-item.ant-menu-item-selected { + position: relative; + } +} + +// 触发器样式 +.ant-layout-sider { + &-dark { + .ant-layout-sider-trigger { + color: darken(@white, 25%); + background: @trigger-dark-bg-color; + + &:hover { + color: @white; + background: @trigger-dark-hover-bg-color; + } + } + } + + &-light { + border-right: 1px solid rgba(221, 221, 221, 0.6); + + .ant-layout-sider-trigger { + color: @text-color-base; + background: @trigger-light-bg-color; + + &:hover { + color: @text-color-base; + background: @trigger-light-hover-bg-color; + } + } + } +} + +.ant-menu-dark { + &.ant-menu-submenu-popup { + > ul { + background: @first-menu-item-dark-bg-color; + } + + .active-menu-style(); + } +} diff --git a/src/components/Menu/src/props.ts b/src/components/Menu/src/props.ts new file mode 100644 index 000000000..510f504e8 --- /dev/null +++ b/src/components/Menu/src/props.ts @@ -0,0 +1,57 @@ +import type { Menu } from '/@/router/types'; +import type { PropType } from 'vue'; + +import { MenuModeEnum, MenuTypeEnum, MenuThemeEnum } from '/@/enums/menuEnum'; +export const basicProps = { + items: { + type: Array as PropType, + default: () => [], + }, + appendClass: { + type: Boolean as PropType, + default: false, + }, + flatItems: { + type: Array as PropType, + default: () => [], + }, + // 是否显示搜索框 + search: { + type: Boolean as PropType, + default: true, + }, + // 最好是4 倍数 + inlineIndent: { + type: Number as PropType, + default: 20, + }, + // 菜单组件的mode属性 + mode: { + type: String as PropType, + default: MenuModeEnum.INLINE, + }, + type: { + type: String as PropType, + default: MenuTypeEnum.MIX, + }, + theme: { + type: String as PropType, + default: MenuThemeEnum.DARK, + }, + showLogo: { + type: Boolean as PropType, + default: false, + }, + inlineCollapsed: { + type: Boolean as PropType, + default: false, + }, + isAppMenu: { + type: Boolean as PropType, + default: true, + }, + beforeClickFn: { + type: Function as PropType, + default: null, + }, +}; diff --git a/src/components/Menu/src/types.d.ts b/src/components/Menu/src/types.d.ts new file mode 100644 index 000000000..a57d8baae --- /dev/null +++ b/src/components/Menu/src/types.d.ts @@ -0,0 +1,25 @@ +export interface MenuState { + // 默认选中的列表 + defaultSelectedKeys: string[]; + + // 模式 + mode: MenuModeEnum; + + // 主题 + theme: ComputedRef | MenuThemeEnum; + + // 缩进 + inlineIndent?: number; + + // 展开数组 + openKeys: string[]; + + // 搜索值 + searchValue: string; + + // 当前选中的菜单项 key 数组 + selectedKeys: string[]; + + // 收缩状态下展开的数组 + collapsedOpenKeys: string[]; +} diff --git a/src/components/Menu/src/useOpenKeys.ts b/src/components/Menu/src/useOpenKeys.ts new file mode 100644 index 000000000..da18cb616 --- /dev/null +++ b/src/components/Menu/src/useOpenKeys.ts @@ -0,0 +1,52 @@ +import type { Menu as MenuType } from '/@/router/types'; +import type { MenuState } from './types'; +import type { Ref } from 'vue'; + +import { unref } from 'vue'; +import { menuStore } from '/@/store/modules/menu'; +import { getAllParentPath } from '/@/utils/helper/menuHelper'; + +export function useOpenKeys( + menuState: MenuState, + menus: Ref, + flatMenusRef: Ref, + isAppMenu: Ref +) { + /** + * @description:设置展开 + */ + function setOpenKeys(menu: MenuType) { + const flatMenus = unref(flatMenusRef); + menuState.openKeys = getAllParentPath(flatMenus, menu.path); + } + /** + * @description: 重置值 + */ + function resetKeys() { + menuState.selectedKeys = []; + menuState.openKeys = []; + } + + function handleOpenChange(openKeys: string[]) { + const rootSubMenuKeys: string[] = []; + for (const menu of unref(menus)) { + const { children, path } = menu; + if (children && children.length > 0) { + rootSubMenuKeys.push(path); + } + } + + if (!menuStore.getCollapsedState || !unref(isAppMenu)) { + const latestOpenKey = openKeys.find((key) => menuState.openKeys.indexOf(key) === -1); + + if (rootSubMenuKeys.indexOf(latestOpenKey as string) === -1) { + menuState.openKeys = openKeys; + } else { + menuState.openKeys = latestOpenKey ? [latestOpenKey] : []; + } + } else { + menuState.collapsedOpenKeys = openKeys; + } + } + return { setOpenKeys, resetKeys, handleOpenChange }; +} diff --git a/src/components/Menu/src/useSearchInput.ts b/src/components/Menu/src/useSearchInput.ts new file mode 100644 index 000000000..248f775dd --- /dev/null +++ b/src/components/Menu/src/useSearchInput.ts @@ -0,0 +1,56 @@ +import type { Menu as MenuType } from '/@/router/types'; +import type { MenuState } from './types'; +import type { Ref } from 'vue'; + +import { isString } from '/@/utils/is'; +import { unref } from 'vue'; +import { es6Unique } from '/@/utils'; +import { getAllParentPath } from '/@/utils/helper/menuHelper'; + +interface UseSearchInputOptions { + menuState: MenuState; + flatMenusRef: Ref; + emit: EmitType; + handleMenuChange: Fn; +} +export function useSearchInput({ + menuState, + flatMenusRef, + handleMenuChange, + emit, +}: UseSearchInputOptions) { + /** + * @description: 输入框搜索 + */ + function handleInputChange(value?: string): void { + if (!isString(value)) { + value = (value as any).target.value; + } + if (!value) { + handleMenuChange && handleMenuChange(); + } + + menuState.searchValue = value || ''; + if (!value) { + menuState.openKeys = []; + return; + } + + const flatMenus = unref(flatMenusRef); + let openKeys: string[] = []; + for (const menu of flatMenus) { + const { name, path } = menu; + if (!name.includes(value)) { + continue; + } + openKeys = openKeys.concat(getAllParentPath(flatMenus, path)); + } + openKeys = es6Unique(openKeys); + menuState.openKeys = openKeys; + } + // 搜索框点击 + function handleInputClick(e: any): void { + emit('clickSearchInput', e); + } + return { handleInputChange, handleInputClick }; +} diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts new file mode 100644 index 000000000..aa6cdc005 --- /dev/null +++ b/src/components/Modal/index.ts @@ -0,0 +1,5 @@ +import './src/index.less'; +export { default as BasicModal } from './src/BasicModal'; +export { default as Modal } from './src/Modal'; +export { useModal, useModalInner } from './src/useModal'; +export * from './src/types'; diff --git a/src/components/Modal/src/BasicModal.tsx b/src/components/Modal/src/BasicModal.tsx new file mode 100644 index 000000000..155380979 --- /dev/null +++ b/src/components/Modal/src/BasicModal.tsx @@ -0,0 +1,230 @@ +import type { ModalProps, ModalMethods } from './types'; + +import Modal from './Modal'; +import { Button } from 'ant-design-vue'; +import ModalWrapper from './ModalWrapper'; +import { BasicTitle } from '/@/components/Basic'; +import { defineComponent, computed, ref, watch, unref, watchEffect } from 'vue'; + +import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined } from '@ant-design/icons-vue'; + +import { basicProps } from './props'; + +import { getSlot, extendSlots } from '/@/utils/helper/tsxHelper'; +import { isFunction } from '/@/utils/is'; +import { deepMerge } from '/@/utils'; +import { buildUUID } from '/@/utils/uuid'; + +// import { triggerWindowResize } from '@/utils/event/triggerWindowResizeEvent'; +export default defineComponent({ + name: 'BasicModal', + props: basicProps, + emits: ['visible-change', 'height-change', 'cancel', 'ok', 'register'], + setup(props, { slots, emit, attrs }) { + const visibleRef = ref(false); + + const propsRef = ref | null>(null); + + const modalWrapperRef = ref(null); + + // modal Bottom and top height + const extHeightRef = ref(0); + + // Unexpanded height of the popup + const formerHeightRef = ref(0); + + const fullScreenRef = ref(false); + // Custom title component: get title + const getMergeProps = computed(() => { + return { + ...props, + ...(unref(propsRef) as any), + }; + }); + // modal component does not need title + const getProps = computed((): any => { + const opt = { + ...props, + ...((unref(propsRef) || {}) as any), + visible: unref(visibleRef), + title: undefined, + }; + const { wrapClassName = '' } = opt; + const className = unref(fullScreenRef) ? `${wrapClassName} fullscreen-modal` : wrapClassName; + return { + ...opt, + wrapClassName: className, + }; + }); + watchEffect(() => { + visibleRef.value = !!props.visible; + }); + watch( + () => unref(visibleRef), + (v) => { + emit('visible-change', v); + }, + { + immediate: false, + } + ); + /** + * @description: 渲染标题 + */ + function renderTitle() { + const { helpMessage } = unref(getProps); + const { title } = unref(getMergeProps); + return ( + + {() => (slots.title ? getSlot(slots, 'title') : title)} + + ); + } + + function renderContent() { + const { useWrapper, loading, wrapperProps } = unref(getProps); + return useWrapper ? ( + { + extHeightRef.value = height; + }} + onHeightChange={(height: string) => { + emit('height-change', height); + }} + > + {() => getSlot(slots)} + + ) : ( + getSlot(slots) + ); + } + // 取消事件 + async function handleCancel(e: Event) { + e.stopPropagation(); + if (props.closeFunc && isFunction(props.closeFunc)) { + const isClose: boolean = await props.closeFunc(); + visibleRef.value = !isClose; + return; + } + visibleRef.value = false; + emit('cancel'); + } + // 底部按钮自定义实现, + function renderFooter() { + const { + showCancelBtn, + cancelButtonProps, + cancelText, + showOkBtn, + okType, + okText, + okButtonProps, + confirmLoading, + } = unref(getProps); + + return ( + <> + {getSlot(slots, 'insertFooter')} + + {showCancelBtn && ( + + )} + {getSlot(slots, 'centerdFooter')} + {showOkBtn && ( + + )} + + {getSlot(slots, 'appendFooter')} + + ); + } + /** + * @description: 关闭按钮 + */ + function renderClose() { + const { canFullscreen } = unref(getProps); + if (!canFullscreen) { + return null; + } + return ( +
    + {unref(fullScreenRef) ? ( + + ) : ( + + )} + +
    + ); + } + + function handleFullScreen(e: Event) { + e.stopPropagation(); + fullScreenRef.value = !unref(fullScreenRef); + + const modalWrapper = unref(modalWrapperRef); + if (modalWrapper) { + const modalWrapSpinEl = (modalWrapper.$el as HTMLElement).querySelector( + '.ant-spin-nested-loading' + ); + if (modalWrapSpinEl) { + if (!unref(formerHeightRef) && unref(fullScreenRef)) { + formerHeightRef.value = (modalWrapSpinEl as HTMLElement).offsetHeight; + console.log(formerHeightRef); + } + if (unref(fullScreenRef)) { + (modalWrapSpinEl as HTMLElement).style.height = `${ + window.innerHeight - unref(extHeightRef) + }px`; + } else { + (modalWrapSpinEl as HTMLElement).style.height = `${unref(formerHeightRef)}px`; + } + } + } + } + /** + * @description: 设置modal参数 + */ + function setModalProps(props: Partial): void { + // Keep the last setModalProps + propsRef.value = deepMerge(unref(propsRef) || {}, props); + if (Reflect.has(props, 'visible')) { + visibleRef.value = !!props.visible; + } + } + + const modalMethods: ModalMethods = { + setModalProps, + }; + const uuid = buildUUID(); + emit('register', modalMethods, uuid); + return () => ( + + {{ + ...extendSlots(slots, ['default']), + default: () => renderContent(), + closeIcon: () => renderClose(), + footer: () => renderFooter(), + title: () => renderTitle(), + }} + + ); + }, +}); diff --git a/src/components/Modal/src/Modal.tsx b/src/components/Modal/src/Modal.tsx new file mode 100644 index 000000000..db1c9cc9a --- /dev/null +++ b/src/components/Modal/src/Modal.tsx @@ -0,0 +1,109 @@ +import { Modal } from 'ant-design-vue'; +import { defineComponent, watchEffect } from 'vue'; +import { basicProps } from './props'; +import { useTimeout } from '/@/hooks/core/useTimeout'; +import { extendSlots } from '/@/utils/helper/tsxHelper'; + +export default defineComponent({ + name: 'Modal', + inheritAttrs: false, + props: basicProps, + setup(props, { attrs, slots }) { + const getStyle = (dom: any, attr: any) => { + return getComputedStyle(dom)[attr]; + }; + const drag = (wrap: any) => { + if (!wrap) return; + wrap.setAttribute('data-drag', props.draggable); + const dialogHeaderEl = wrap.querySelector('.ant-modal-header'); + const dragDom = wrap.querySelector('.ant-modal'); + + if (!dialogHeaderEl || !dragDom || !props.draggable) return; + + dialogHeaderEl.style.cursor = 'move'; + + dialogHeaderEl.onmousedown = (e: any) => { + // 鼠标按下,计算当前元素距离可视区的距离 + const disX = e.clientX; + const disY = e.clientY; + const screenWidth = document.body.clientWidth; // body当前宽度 + const screenHeight = document.documentElement.clientHeight; // 可见区域高度(应为body高度,可某些环境下无法获取) + + const dragDomWidth = dragDom.offsetWidth; // 对话框宽度 + const dragDomheight = dragDom.offsetHeight; // 对话框高度 + + const minDragDomLeft = dragDom.offsetLeft; + + const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth; + const minDragDomTop = dragDom.offsetTop; + const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomheight; + // 获取到的值带px 正则匹配替换 + const domLeft = getStyle(dragDom, 'left'); + const domTop = getStyle(dragDom, 'top'); + let styL = +domLeft; + let styT = +domTop; + + // 注意在ie中 第一次获取到的值为组件自带50% 移动之后赋值为px + if (domLeft.includes('%')) { + styL = +document.body.clientWidth * (+domLeft.replace(/%/g, '') / 100); + styT = +document.body.clientHeight * (+domTop.replace(/%/g, '') / 100); + } else { + styL = +domLeft.replace(/px/g, ''); + styT = +domTop.replace(/px/g, ''); + } + + document.onmousemove = function (e) { + // 通过事件委托,计算移动的距离 + let left = e.clientX - disX; + let top = e.clientY - disY; + + // 边界处理 + if (-left > minDragDomLeft) { + left = -minDragDomLeft; + } else if (left > maxDragDomLeft) { + left = maxDragDomLeft; + } + + if (-top > minDragDomTop) { + top = -minDragDomTop; + } else if (top > maxDragDomTop) { + top = maxDragDomTop; + } + + // 移动当前元素 + dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;`; + }; + + document.onmouseup = () => { + document.onmousemove = null; + document.onmouseup = null; + }; + }; + }; + const handleDrag = () => { + const dragWraps = document.querySelectorAll('.ant-modal-wrap'); + for (const wrap of dragWraps as any) { + const display = getStyle(wrap, 'display'); + const draggable = wrap.getAttribute('data-drag'); + if (display !== 'none') { + // 拖拽位置 + draggable === null && drag(wrap); + } + } + }; + watchEffect(() => { + if (!props.visible) { + return; + } + // context.$nextTick(); + useTimeout(() => { + handleDrag(); + }, 30); + }); + + return () => { + const propsData = { ...attrs, ...props } as any; + return {extendSlots(slots)}; + }; + }, +}); diff --git a/src/components/Modal/src/ModalWrapper.tsx b/src/components/Modal/src/ModalWrapper.tsx new file mode 100644 index 000000000..818b4cc16 --- /dev/null +++ b/src/components/Modal/src/ModalWrapper.tsx @@ -0,0 +1,188 @@ +import type { PropType } from 'vue'; +import type { ModalWrapperProps } from './types'; + +import { + defineComponent, + computed, + ref, + watchEffect, + unref, + watch, + onMounted, + nextTick, + onUnmounted, +} from 'vue'; +import { Spin } from 'ant-design-vue'; +import { ScrollContainer } from '/@/components/Container/index'; + +import { useWindowSizeFn } from '/@/hooks/event/useWindowSize'; +import { useTimeout } from '/@/hooks/core/useTimeout'; + +import { getSlot } from '/@/utils/helper/tsxHelper'; +import { useElResize } from '/@/hooks/event/useElResize'; +export default defineComponent({ + name: 'ModalWrapper', + emits: ['heightChange', 'getExtHeight'], + props: { + loading: { + type: Boolean as PropType, + default: false, + }, + modalHeaderHeight: { + type: Number as PropType, + default: 50, + }, + modalFooterHeight: { + type: Number as PropType, + default: 70, + }, + minHeight: { + type: Number as PropType, + default: 200, + }, + footerOffset: { + type: Number as PropType, + default: 0, + }, + visible: { + type: Boolean as PropType, + default: false, + }, + fullScreen: { + type: Boolean as PropType, + default: false, + }, + }, + setup(props: ModalWrapperProps, { slots, emit }) { + const wrapperRef = ref(null); + const spinRef = ref(null); + const realHeightRef = ref(0); + + const wrapStyle = computed(() => { + return { + minHeight: `${props.minHeight}px`, + overflow: 'hidden', + }; + }); + + // 重试次数 + let tryCount = 0; + async function setModalHeight() { + // 解决在弹窗关闭的时候监听还存在,导致再次打开弹窗没有高度 + // 加上这个,就必须在使用的时候传递父级的visible + if (!props.visible) { + return; + } + const wrapperRefDom = unref(wrapperRef); + if (!wrapperRefDom) { + return; + } + const bodyDom = wrapperRefDom.parentElement; + if (!bodyDom) { + return; + } + bodyDom.style.padding = '0'; + await nextTick(); + + try { + const modalDom = bodyDom.parentElement && bodyDom.parentElement.parentElement; + if (!modalDom) { + return; + } + const modalRect = getComputedStyle(modalDom).top; + const modalTop = Number.parseInt(modalRect); + let maxHeight = + window.innerHeight - + modalTop * 2 + + (props.footerOffset! || 0) - + props.modalFooterHeight - + props.modalHeaderHeight; + + // 距离顶部过进会出现滚动条 + if (modalTop < 40) { + maxHeight -= 26; + } + await nextTick(); + const spinEl = unref(spinRef); + if (!spinEl) { + useTimeout(() => { + // retry + if (tryCount < 3) { + setModalHeight(); + } + tryCount++; + }, 10); + return; + } + tryCount = 0; + + const realHeight = (spinEl.$el.querySelector('.ant-spin-container') as HTMLElement) + .scrollHeight; + + // 16为 p-2和m-2 加起来为4,基础4, 4*4=16 + // 32 padding + if (props.fullScreen) { + realHeightRef.value = + window.innerHeight - props.modalFooterHeight - props.modalHeaderHeight - 26; + } else { + realHeightRef.value = realHeight > maxHeight ? maxHeight : realHeight + 16 + 30; + } + emit('heightChange', unref(realHeightRef)); + nextTick(() => { + const el = spinEl.$el; + if (el) { + el.style.height = `${unref(realHeightRef)}px`; + } + }); + } catch (error) { + console.log(error); + } + } + function listenElResize() { + const wrapper = unref(wrapperRef); + if (!wrapper) return; + const container = wrapper.querySelector('.ant-spin-container'); + if (!container) return; + const [start, stop] = useElResize(container, () => { + setModalHeight(); + }); + start(); + onUnmounted(() => { + stop(); + }); + } + nextTick(() => {}); + watchEffect(() => { + setModalHeight(); + }); + watch( + () => props.fullScreen, + (v) => { + !v && setModalHeight(); + } + ); + + onMounted(() => { + const { modalHeaderHeight, modalFooterHeight } = props; + emit('getExtHeight', modalHeaderHeight + modalFooterHeight); + listenElResize(); + }); + + useWindowSizeFn(setModalHeight); + + return () => { + const height = unref(realHeightRef); + return ( +
    + + {() => ( + + {() => getSlot(slots)} + + )} + +
    + ); + }; + }, +}); diff --git a/src/components/Modal/src/index.less b/src/components/Modal/src/index.less new file mode 100644 index 000000000..f44e1be60 --- /dev/null +++ b/src/components/Modal/src/index.less @@ -0,0 +1,151 @@ +@import (reference) '../../../design/index.less'; + +.fullscreen-modal { + overflow: hidden; + + .ant-modal { + top: 0 !important; + right: 0 !important; + bottom: 0 !important; + left: 0 !important; + width: 100% !important; + } +} + +.ant-modal { + width: 520px; + padding-bottom: 0; + + .ant-spin-nested-loading { + padding: 16px; + } + + &-title { + font-size: 16px; + font-weight: bold; + line-height: 16px; + + .base-title { + cursor: move; + } + } + + .custom-close-icon { + display: flex; + height: 95%; + align-items: center; + + > * { + margin-left: 12px; + } + + & span:nth-child(1) { + display: inline-block; + padding: 10px; + + &:hover { + color: @primary-color; + } + } + + & span:nth-child(2) { + &:hover { + color: @error-color; + } + } + } + + .ant-modal-body { + // background: #f1f2f6; + padding: 0; + } + + &-large { + top: 60px; + + &--mini { + top: 16px; + } + } + + &-header { + // padding: 12.5px 24px; + padding: 16px; + } + + &-content { + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); + } + + &-footer { + padding: 10px 26px 26px 16px; + // border-top: none; + + button + button { + margin-left: 10px; + } + } + + &-close { + font-weight: normal; + outline: none; + } + + &-close-x { + display: inline-block; + width: 96px; + height: 56px; + line-height: 56px; + } + + &-confirm-body { + .ant-modal-confirm-content { + color: @text-color-help-dark; + + > * { + color: @text-color-help-dark; + } + } + } + + &-confirm-confirm.error .ant-modal-confirm-body > .anticon { + color: @error-color; + } + + &-confirm-btns { + .ant-btn:last-child { + margin-right: 0; + } + } + + &-confirm-info { + .ant-modal-confirm-body > .anticon { + color: @warning-color; + } + } + + &-confirm-confirm.success { + .ant-modal-confirm-body > .anticon { + color: @success-color; + } + } +} + +.ant-modal-confirm .ant-modal-body { + padding: 24px !important; +} +@media screen and (max-height: 600px) { + .ant-modal { + top: 60px; + } +} +@media screen and (max-height: 540px) { + .ant-modal { + top: 30px; + } +} +@media screen and (max-height: 480px) { + .ant-modal { + top: 10px; + } +} diff --git a/src/components/Modal/src/props.ts b/src/components/Modal/src/props.ts new file mode 100644 index 000000000..39cd7d259 --- /dev/null +++ b/src/components/Modal/src/props.ts @@ -0,0 +1,122 @@ +import type { PropType } from 'vue'; +export const modalProps = { + visible: Boolean as PropType, + // open drag + draggable: { + type: Boolean as PropType, + default: true, + }, + centered: { + type: Boolean as PropType, + default: false, + }, + cancelText: { + type: String as PropType, + default: '关闭', + }, + okText: { + type: String as PropType, + default: '保存', + }, + closeFunc: Function as PropType<() => Promise>, +}; + +export const basicProps = Object.assign({}, modalProps, { + // Can it be full screen + canFullscreen: { + type: Boolean as PropType, + default: true, + }, + // After enabling the wrapper, the bottom can be increased in height + wrapperFooterOffset: { + type: Number as PropType, + default: 0, + }, + // Warm reminder message + helpMessage: [String, Array] as PropType, + // Whether to use wrapper + useWrapper: { + type: Boolean as PropType, + default: true, + }, + loading: { + type: Boolean as PropType, + default: false, + }, + /** + * @description: Show close button + */ + showCancelBtn: { + type: Boolean as PropType, + default: true, + }, + /** + * @description: Show confirmation button + */ + showOkBtn: { + type: Boolean as PropType, + default: true, + }, + + wrapperProps: Object as PropType, + + afterClose: Function as PropType<() => Promise>, + + bodyStyle: Object as PropType, + + closable: { + type: Boolean as PropType, + default: true, + }, + + closeIcon: Object as PropType, + + confirmLoading: Boolean as PropType, + + destroyOnClose: Boolean as PropType, + + footer: Object as PropType, + + getContainer: Function as PropType<() => any>, + + mask: { + type: Boolean as PropType, + default: true, + }, + + maskClosable: { + type: Boolean as PropType, + default: true, + }, + keyboard: { + type: Boolean as PropType, + default: true, + }, + + maskStyle: Object as PropType, + + okType: { + type: String as PropType, + default: 'primary', + }, + + okButtonProps: Object as PropType, + + cancelButtonProps: Object as PropType, + + title: { + type: String as PropType, + }, + + visible: Boolean as PropType, + + width: [String, Number] as PropType, + + wrapClassName: { + type: String as PropType, + }, + + zIndex: { + type: Number as PropType, + }, +}); diff --git a/src/components/Modal/src/types.ts b/src/components/Modal/src/types.ts new file mode 100644 index 000000000..150b9b9a5 --- /dev/null +++ b/src/components/Modal/src/types.ts @@ -0,0 +1,195 @@ +import type { ButtonProps } from 'ant-design-vue/types/button/button'; +import type { CSSProperties, VNodeChild } from 'vue'; +/** + * @description: 弹窗对外暴露的方法 + */ +export interface ModalMethods { + setModalProps: (props: Partial) => void; +} + +export type RegisterFn = (modalMethods: ModalMethods, uuid?: string) => void; +export interface ReturnMethods extends ModalMethods { + openModal: (props?: boolean) => void; + transferModalData: (data: any) => void; +} +export type UseModalReturnType = [RegisterFn, ReturnMethods]; + +export interface ReturnInnerMethods extends ModalMethods { + closeModal: () => void; + changeLoading: (loading: boolean) => void; + changeOkLoading: (loading: boolean) => void; + receiveModalDataRef: any; +} +export type UseModalInnerReturnType = [RegisterFn, ReturnInnerMethods]; + +export interface ModalProps { + // 启用wrapper后 底部可以适当增加高度 + wrapperFooterOffset?: number; + draggable?: boolean; + + // 是否可以进行全屏 + canFullscreen?: boolean; + visible?: boolean; + // 温馨提醒信息 + helpMessage: string | string[]; + + // 是否使用modalWrapper + useWrapper: boolean; + + loading: boolean; + + wrapperProps: Omit; + + showOkBtn: boolean; + showCancelBtn: boolean; + closeFunc: () => Promise; + + /** + * Specify a function that will be called when modal is closed completely. + * @type Function + */ + afterClose?: () => any; + + /** + * Body style for modal body element. Such as height, padding etc. + * @default {} + * @type object + */ + bodyStyle?: CSSProperties; + + /** + * Text of the Cancel button + * @default 'cancel' + * @type string + */ + cancelText?: string; + + /** + * Centered Modal + * @default false + * @type boolean + */ + centered?: boolean; + + /** + * Whether a close (x) button is visible on top right of the modal dialog or not + * @default true + * @type boolean + */ + closable?: boolean; + /** + * Whether a close (x) button is visible on top right of the modal dialog or not + */ + closeIcon?: VNodeChild | JSX.Element; + + /** + * Whether to apply loading visual effect for OK button or not + * @default false + * @type boolean + */ + confirmLoading?: boolean; + + /** + * Whether to unmount child components on onClose + * @default false + * @type boolean + */ + destroyOnClose?: boolean; + + /** + * Footer content, set as :footer="null" when you don't need default buttons + * @default OK and Cancel buttons + * @type any (string | slot) + */ + footer?: VNodeChild | JSX.Element; + + /** + * Return the mount node for Modal + * @default () => document.body + * @type Function + */ + getContainer?: (instance: any) => HTMLElement; + + /** + * Whether show mask or not. + * @default true + * @type boolean + */ + mask?: boolean; + + /** + * Whether to close the modal dialog when the mask (area outside the modal) is clicked + * @default true + * @type boolean + */ + maskClosable?: boolean; + + /** + * Style for modal's mask element. + * @default {} + * @type object + */ + maskStyle?: CSSProperties; + + /** + * Text of the OK button + * @default 'OK' + * @type string + */ + okText?: string; + + /** + * Button type of the OK button + * @default 'primary' + * @type string + */ + okType?: 'primary' | 'danger' | 'dashed' | 'ghost' | 'default'; + + /** + * The ok button props, follow jsx rules + * @type object + */ + okButtonProps?: ButtonProps; + + /** + * The cancel button props, follow jsx rules + * @type object + */ + cancelButtonProps?: ButtonProps; + + /** + * The modal dialog's title + * @type any (string | slot) + */ + title?: VNodeChild | JSX.Element; + + /** + * Width of the modal dialog + * @default 520 + * @type string | number + */ + width?: string | number; + + /** + * The class name of the container of the modal dialog + * @type string + */ + wrapClassName?: string; + + /** + * The z-index of the Modal + * @default 1000 + * @type number + */ + zIndex?: number; +} + +export interface ModalWrapperProps { + footerOffset?: number; + loading: boolean; + modalHeaderHeight: number; + modalFooterHeight: number; + minHeight: number; + visible: boolean; + fullScreen: boolean; +} diff --git a/src/components/Modal/src/useModal.ts b/src/components/Modal/src/useModal.ts new file mode 100644 index 000000000..041122341 --- /dev/null +++ b/src/components/Modal/src/useModal.ts @@ -0,0 +1,99 @@ +import type { + UseModalReturnType, + ModalMethods, + ModalProps, + ReturnMethods, + UseModalInnerReturnType, +} from './types'; +import { ref, onUnmounted, unref, getCurrentInstance, reactive, computed } from 'vue'; +import { isProdMode } from '/@/utils/env'; +const dataTransferRef = reactive({}); + +/** + * @description: Applicable to independent modal and call outside + */ +export function useModal(): UseModalReturnType { + if (!getCurrentInstance()) { + throw new Error('Please put useModal function in the setup function!'); + } + const modalRef = ref>(null); + const loadedRef = ref>(false); + const uidRef = ref(''); + function register(modalMethod: ModalMethods, uuid: string) { + uidRef.value = uuid; + isProdMode() && + onUnmounted(() => { + modalRef.value = null; + loadedRef.value = false; + dataTransferRef[unref(uidRef)] = null; + }); + if (unref(loadedRef) && isProdMode() && modalMethod === unref(modalRef)) { + return; + } + modalRef.value = modalMethod; + } + const getInstance = () => { + const instance = unref(modalRef); + if (!instance) { + throw new Error('instance is undefined!'); + } + return instance; + }; + + const methods: ReturnMethods = { + setModalProps: (props: Partial): void => { + getInstance().setModalProps(props); + }, + openModal: (visible = true): void => { + getInstance().setModalProps({ + visible: visible, + }); + }, + transferModalData(val: any) { + dataTransferRef[unref(uidRef)] = val; + }, + }; + return [register, methods]; +} + +export const useModalInner = (): UseModalInnerReturnType => { + const modalInstanceRef = ref(null); + const currentInstall = getCurrentInstance(); + const uidRef = ref(''); + + if (!currentInstall) { + throw new Error('instance is undefined!'); + } + const getInstance = () => { + const instance = unref(modalInstanceRef); + if (!instance) { + throw new Error('instance is undefined!'); + } + return instance; + }; + const register = (modalInstance: ModalMethods, uuid: string) => { + uidRef.value = uuid; + modalInstanceRef.value = modalInstance; + currentInstall.emit('register', modalInstance); + }; + return [ + register, + { + receiveModalDataRef: computed(() => { + return dataTransferRef[unref(uidRef)]; + }), + changeLoading: (loading = true) => { + getInstance().setModalProps({ loading }); + }, + changeOkLoading: (loading = true) => { + getInstance().setModalProps({ confirmLoading: loading }); + }, + closeModal: () => { + getInstance().setModalProps({ visible: false }); + }, + setModalProps: (props: Partial) => { + getInstance().setModalProps(props); + }, + }, + ]; +}; diff --git a/src/components/Preview/index.ts b/src/components/Preview/index.ts new file mode 100644 index 000000000..812b10543 --- /dev/null +++ b/src/components/Preview/index.ts @@ -0,0 +1 @@ +export { createImgPreview } from './src/functional'; diff --git a/src/components/Preview/src/functional.ts b/src/components/Preview/src/functional.ts new file mode 100644 index 000000000..b37153aef --- /dev/null +++ b/src/components/Preview/src/functional.ts @@ -0,0 +1,21 @@ +import ImgPreview from './index'; +import { isClient } from '/@/utils/is'; + +import type { Options, Props } from './types'; + +import { createApp } from 'vue'; + +export function createImgPreview(options: Options) { + if (!isClient) return; + const { imageList, show = true, index = 0 } = options; + + const propsData: Partial = {}; + const wrapDom = document.createElement('div'); + propsData.imageList = imageList; + propsData.show = show; + propsData.index = index; + const imgDom = createApp(ImgPreview, propsData); + imgDom.mount(wrapDom); + const imgPreviewDom = wrapDom.children[0]; + document.body.appendChild(imgPreviewDom); +} diff --git a/src/components/Preview/src/index.less b/src/components/Preview/src/index.less new file mode 100644 index 000000000..091a344fc --- /dev/null +++ b/src/components/Preview/src/index.less @@ -0,0 +1,119 @@ +@import (reference) '../../../design/index.less'; + +.img-preview { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.5); + user-select: none; + + &-content { + display: flex; + width: 100%; + height: 100%; + color: @white; + justify-content: center; + align-items: center; + } + + &-image { + cursor: pointer; + transition: transform 0.3s; + } + + &__close { + position: absolute; + top: -40px; + right: -40px; + width: 80px; + height: 80px; + overflow: hidden; + color: @white; + cursor: pointer; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + transition: all 0.2s; + + &-icon { + position: absolute; + top: 46px; + left: 16px; + font-size: 16px; + } + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + } + } + + &__index { + position: absolute; + bottom: 5%; + left: 50%; + padding: 0 22px; + font-size: 16px; + background: rgba(109, 109, 109, 0.6); + border-radius: 15px; + transform: translateX(-50%); + } + + &__controller { + position: absolute; + bottom: 10%; + left: 50%; + display: flex; + width: 260px; + height: 44px; + padding: 0 22px; + margin-left: -139px; + background: rgba(109, 109, 109, 0.6); + border-radius: 22px; + justify-content: center; + align-items: center; + + &-item { + padding: 0 9px; + font-size: 24px; + line-height: 44px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + transform: scale(1.2); + } + + img { + width: 1em; + } + } + } + + &__arrow { + position: absolute; + top: 50%; + width: 50px; + height: 50px; + font-size: 28px; + line-height: 50px; + text-align: center; + cursor: pointer; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + transition: all 0.2s; + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + } + + &.left { + left: 50px; + } + + &.right { + right: 50px; + } + } +} diff --git a/src/components/Preview/src/index.tsx b/src/components/Preview/src/index.tsx new file mode 100644 index 000000000..79a84ff6f --- /dev/null +++ b/src/components/Preview/src/index.tsx @@ -0,0 +1,318 @@ +import { defineComponent, ref, unref, computed, reactive, watch } from 'vue'; + +import { FadeTransition } from '/@/components/Transition/index'; + +import { basicProps } from './props'; +import { Props } from './types'; +import './index.less'; + +import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'; + +import resumeSvg from '/@/assets/svg/preview/resume.svg'; +import rotateSvg from '/@/assets/svg/preview/p-rotate.svg'; +import scaleSvg from '/@/assets/svg/preview/scale.svg'; +import unscaleSvg from '/@/assets/svg/preview/unscale.svg'; +import loadingSvg from '/@/assets/images/loading.svg'; +import unrotateSvg from '/@/assets/svg/preview/unrotate.svg'; +enum StatueEnum { + LOADING, + DONE, + FAIL, +} +interface ImgState { + currentUrl: string; + imgScale: number; + imgRotate: number; + imgTop: number; + imgLeft: number; + currentIndex: number; + status: StatueEnum; + moveX: number; + moveY: number; +} + +const prefixCls = 'img-preview'; +export default defineComponent({ + name: 'ImagePreview', + props: basicProps, + setup(props: Props) { + const imgState = reactive({ + currentUrl: '', + imgScale: 1, + imgRotate: 0, + imgTop: 0, + imgLeft: 0, + status: StatueEnum.LOADING, + currentIndex: 0, + moveX: 0, + moveY: 0, + }); + const wrapElRef = ref(null); + const imgElRef = ref(null); + + // 初始化 + function init() { + initMouseWheel(); + const { index, imageList } = props; + + if (!imageList || !imageList.length) { + throw new Error('imageList is undefined'); + } + imgState.currentIndex = index; + handleIChangeImage(imageList[index]); + } + + // 重置 + function initState() { + imgState.imgScale = 1; + imgState.imgRotate = 0; + imgState.imgTop = 0; + imgState.imgLeft = 0; + } + + // 初始化鼠标滚轮事件 + function initMouseWheel() { + const wrapEl = unref(wrapElRef); + if (!wrapEl) { + return; + } + (wrapEl as any).onmousewheel = scrollFunc; + // 火狐浏览器没有onmousewheel事件,用DOMMouseScroll代替 + document.body.addEventListener('DOMMouseScroll', scrollFunc); + // 禁止火狐浏览器下拖拽图片的默认事件 + document.ondragstart = function () { + return false; + }; + } + + // 监听鼠标滚轮 + function scrollFunc(e: any) { + e = e || window.event; + e.delta = e.wheelDelta || -e.detail; + + e.preventDefault(); + if (e.delta > 0) { + // 滑轮向上滚动 + scaleFunc(0.015); + } + if (e.delta < 0) { + // 滑轮向下滚动 + scaleFunc(-0.015); + } + } + // 缩放函数 + function scaleFunc(num: number) { + if (imgState.imgScale <= 0.2 && num < 0) return; + imgState.imgScale += num; + } + + // 旋转图片 + function rotateFunc(deg: number) { + imgState.imgRotate += deg; + } + + // 鼠标事件 + function handleMouseUp() { + const imgEl = unref(imgElRef); + if (!imgEl) return; + imgEl.onmousemove = null; + } + + // 更换图片 + function handleIChangeImage(url: string) { + imgState.status = StatueEnum.LOADING; + const img = new Image(); + img.src = url; + img.onload = () => { + imgState.currentUrl = url; + imgState.status = StatueEnum.DONE; + }; + img.onerror = () => { + imgState.status = StatueEnum.FAIL; + }; + } + + // 关闭 + function handleClose() { + const { instance } = props; + if (instance) { + instance.show = false; + } + // 移除火狐浏览器下的鼠标滚动事件 + document.body.removeEventListener('DOMMouseScroll', scrollFunc); + // 恢复火狐及Safari浏览器下的图片拖拽 + document.ondragstart = null; + } + // 图片复原 + function resume() { + initState(); + } + + // 上一页下一页 + function handleChange(direction: 'left' | 'right') { + const { currentIndex } = imgState; + const { imageList } = props; + if (direction === 'left') { + imgState.currentIndex--; + if (currentIndex <= 0) { + imgState.currentIndex = imageList.length - 1; + } + } + if (direction === 'right') { + imgState.currentIndex++; + if (currentIndex >= imageList.length - 1) { + imgState.currentIndex = 0; + } + } + handleIChangeImage(imageList[imgState.currentIndex]); + } + + function handleAddMoveListener(e: MouseEvent) { + e = e || window.event; + imgState.moveX = e.clientX; + imgState.moveY = e.clientY; + const imgEl = unref(imgElRef); + if (imgEl) { + imgEl.onmousemove = moveFunc; + } + } + + function moveFunc(e: MouseEvent) { + e = e || window.event; + e.preventDefault(); + const movementX = e.clientX - imgState.moveX; + const movementY = e.clientY - imgState.moveY; + imgState.imgLeft += movementX; + imgState.imgTop += movementY; + imgState.moveX = e.clientX; + imgState.moveY = e.clientY; + } + + // 获取图片样式 + const getImageStyle = computed(() => { + const { imgScale, imgRotate, imgTop, imgLeft } = imgState; + return { + transform: `scale(${imgScale}) rotate(${imgRotate}deg)`, + marginTop: `${imgTop}px`, + marginLeft: `${imgLeft}px`, + }; + }); + + const getIsMultipleImage = computed(() => { + const { imageList } = props; + return imageList.length > 1; + }); + watch( + () => props.show, + (show) => { + if (show) { + init(); + } + }, + { + immediate: true, + } + ); + watch( + () => props.imageList, + () => { + initState(); + }, + { + immediate: true, + } + ); + + const renderClose = () => { + return ( +
    + +
    + ); + }; + const renderIndex = () => { + if (!unref(getIsMultipleImage)) { + return null; + } + const { currentIndex } = imgState; + const { imageList } = props; + return ( +
    + {currentIndex + 1} / {imageList.length} +
    + ); + }; + + const renderController = () => { + return ( +
    +
    scaleFunc(-0.15)}> + +
    +
    scaleFunc(0.15)}> + +
    +
    + +
    +
    rotateFunc(-90)}> + +
    +
    rotateFunc(90)}> + +
    +
    + ); + }; + + const renderArrow = (direction: 'left' | 'right') => { + if (!unref(getIsMultipleImage)) { + return null; + } + return ( +
    handleChange(direction)}> + {direction === 'left' ? : } +
    + ); + }; + return () => { + return ( + + {() => + props.show && ( +
    +
    + + + + {renderClose()} + {renderIndex()} + {renderController()} + {renderArrow('left')} + {renderArrow('right')} +
    +
    + ) + } +
    + ); + }; + }, +}); diff --git a/src/components/Preview/src/props.ts b/src/components/Preview/src/props.ts new file mode 100644 index 000000000..796a5ce35 --- /dev/null +++ b/src/components/Preview/src/props.ts @@ -0,0 +1,20 @@ +import { PropType } from 'vue'; +import { Props } from './types'; +export const basicProps = { + show: { + type: Boolean as PropType, + default: false, + }, + instance: { + type: Object as PropType, + default: null, + }, + imageList: { + type: [Array] as PropType, + default: null, + }, + index: { + type: Number as PropType, + default: 0, + }, +}; diff --git a/src/components/Preview/src/types.ts b/src/components/Preview/src/types.ts new file mode 100644 index 000000000..f624ffa6e --- /dev/null +++ b/src/components/Preview/src/types.ts @@ -0,0 +1,12 @@ +export interface Options { + show?: boolean; + imageList: string[]; + index?: number; +} + +export interface Props { + show: boolean; + instance: Props; + imageList: string[]; + index: number; +} diff --git a/src/components/Qrcode/index.ts b/src/components/Qrcode/index.ts new file mode 100644 index 000000000..9ccd00c4a --- /dev/null +++ b/src/components/Qrcode/index.ts @@ -0,0 +1,2 @@ +export { default as QrCode } from './src/index.vue'; +export * from './src/types'; diff --git a/src/components/Qrcode/src/drawCanvas.ts b/src/components/Qrcode/src/drawCanvas.ts new file mode 100644 index 000000000..20a10b99f --- /dev/null +++ b/src/components/Qrcode/src/drawCanvas.ts @@ -0,0 +1,29 @@ +import { toCanvas } from 'qrcode'; +import type { QRCodeRenderersOptions } from 'qrcode'; +import { RenderQrCodeParams, ContentType } from './types'; +export const renderQrCode = ({ canvas, content, width = 0, options = {} }: RenderQrCodeParams) => { + // 容错率,默认对内容少的二维码采用高容错率,内容多的二维码采用低容错率 + options.errorCorrectionLevel = options.errorCorrectionLevel || getErrorCorrectionLevel(content); + + return getOriginWidth(content, options).then((_width: number) => { + options.scale = width === 0 ? undefined : (width / _width) * 4; + return toCanvas(canvas, content, options); + }); +}; + +// 得到原QrCode的大小,以便缩放得到正确的QrCode大小 +function getOriginWidth(content: ContentType, options: QRCodeRenderersOptions) { + const _canvas = document.createElement('canvas'); + return toCanvas(_canvas, content, options).then(() => _canvas.width); +} + +// 对于内容少的QrCode,增大容错率 +function getErrorCorrectionLevel(content: ContentType) { + if (content.length > 36) { + return 'M'; + } else if (content.length > 16) { + return 'Q'; + } else { + return 'H'; + } +} diff --git a/src/components/Qrcode/src/drawLogo.ts b/src/components/Qrcode/src/drawLogo.ts new file mode 100644 index 000000000..d0c914239 --- /dev/null +++ b/src/components/Qrcode/src/drawLogo.ts @@ -0,0 +1,89 @@ +import { isString } from '/@/utils/is'; +import { RenderQrCodeParams, LogoType } from './types'; +export const drawLogo = ({ canvas, logo }: RenderQrCodeParams) => { + if (!logo) { + return new Promise((resolve) => { + resolve((canvas as HTMLCanvasElement).toDataURL()); + }); + } + + const canvasWidth = (canvas as HTMLCanvasElement).width; + const { + logoSize = 0.15, + bgColor = '#ffffff', + borderSize = 0.05, + crossOrigin, + borderRadius = 8, + logoRadius = 0, + } = logo as LogoType; + + const logoSrc: string = isString(logo) ? logo : logo.src; + const logoWidth = canvasWidth * logoSize; + const logoXY = (canvasWidth * (1 - logoSize)) / 2; + const logoBgWidth = canvasWidth * (logoSize + borderSize); + const logoBgXY = (canvasWidth * (1 - logoSize - borderSize)) / 2; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // logo 底色 + canvasRoundRect(ctx)(logoBgXY, logoBgXY, logoBgWidth, logoBgWidth, borderRadius); + ctx.fillStyle = bgColor; + ctx.fill(); + + // logo + const image = new Image(); + if (crossOrigin || logoRadius) { + image.setAttribute('crossOrigin', crossOrigin || 'anonymous'); + } + image.src = logoSrc; + + // 使用image绘制可以避免某些跨域情况 + const drawLogoWithImage = (image: CanvasImageSource) => { + ctx.drawImage(image, logoXY, logoXY, logoWidth, logoWidth); + }; + + // 使用canvas绘制以获得更多的功能 + const drawLogoWithCanvas = (image: HTMLImageElement) => { + const canvasImage = document.createElement('canvas'); + canvasImage.width = logoXY + logoWidth; + canvasImage.height = logoXY + logoWidth; + const imageCanvas = canvasImage.getContext('2d'); + if (!imageCanvas || !ctx) return; + imageCanvas.drawImage(image, logoXY, logoXY, logoWidth, logoWidth); + + canvasRoundRect(ctx)(logoXY, logoXY, logoWidth, logoWidth, logoRadius); + if (!ctx) return; + const fillStyle = ctx.createPattern(canvasImage, 'no-repeat'); + if (fillStyle) { + ctx.fillStyle = fillStyle; + ctx.fill(); + } + }; + + // 将 logo绘制到 canvas上 + return new Promise((resolve) => { + image.onload = () => { + logoRadius ? drawLogoWithCanvas(image) : drawLogoWithImage(image); + resolve((canvas as HTMLCanvasElement).toDataURL()); + }; + }); +}; + +// copy来的方法,用于绘制圆角 +function canvasRoundRect(ctx: CanvasRenderingContext2D) { + return (x: number, y: number, w: number, h: number, r: number) => { + const minSize = Math.min(w, h); + if (r > minSize / 2) { + r = minSize / 2; + } + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + return ctx; + }; +} diff --git a/src/components/Qrcode/src/index.vue b/src/components/Qrcode/src/index.vue new file mode 100644 index 000000000..436be70da --- /dev/null +++ b/src/components/Qrcode/src/index.vue @@ -0,0 +1,101 @@ + + diff --git a/src/components/Qrcode/src/qrcodePlus.ts b/src/components/Qrcode/src/qrcodePlus.ts new file mode 100644 index 000000000..00772bc7e --- /dev/null +++ b/src/components/Qrcode/src/qrcodePlus.ts @@ -0,0 +1,5 @@ +// 参考 qr-code-with-logo 进行ts版本修改 +import { toCanvas } from './toCanvas'; +export * from './types'; + +export { toCanvas }; diff --git a/src/components/Qrcode/src/toCanvas.ts b/src/components/Qrcode/src/toCanvas.ts new file mode 100644 index 000000000..30ef15701 --- /dev/null +++ b/src/components/Qrcode/src/toCanvas.ts @@ -0,0 +1,10 @@ +import { renderQrCode } from './drawCanvas'; +import { drawLogo } from './drawLogo'; +import { RenderQrCodeParams } from './types'; +export const toCanvas = (options: RenderQrCodeParams) => { + return renderQrCode(options) + .then(() => { + return options; + }) + .then(drawLogo) as Promise; +}; diff --git a/src/components/Qrcode/src/types.ts b/src/components/Qrcode/src/types.ts new file mode 100644 index 000000000..1b0ce11cb --- /dev/null +++ b/src/components/Qrcode/src/types.ts @@ -0,0 +1,33 @@ +import type { QRCodeSegment, QRCodeRenderersOptions } from 'qrcode'; + +export type ContentType = string | QRCodeSegment[]; + +export type { QRCodeRenderersOptions }; + +export type LogoType = { + src: string; + logoSize: number; + borderColor: string; + bgColor: string; + borderSize: number; + crossOrigin: string; + borderRadius: number; + logoRadius: number; +}; + +export interface RenderQrCodeParams { + canvas: any; + content: ContentType; + width?: number; + options?: QRCodeRenderersOptions; + logo?: LogoType | string; + image?: HTMLImageElement; + downloadName?: string; + download?: boolean | Fn; +} + +export type ToCanvasFn = (options: RenderQrCodeParams) => Promise; + +export interface QrCodeActionType { + download: (fileName?: string) => void; +} diff --git a/src/components/Scrollbar/index.ts b/src/components/Scrollbar/index.ts new file mode 100644 index 000000000..b509e1431 --- /dev/null +++ b/src/components/Scrollbar/index.ts @@ -0,0 +1,5 @@ +/** + * copy from element-ui + */ + +export { default as Scrollbar } from './src/Scrollbar'; diff --git a/src/components/Scrollbar/src/Bar.tsx b/src/components/Scrollbar/src/Bar.tsx new file mode 100644 index 000000000..c96922f97 --- /dev/null +++ b/src/components/Scrollbar/src/Bar.tsx @@ -0,0 +1,106 @@ +import type { PropType } from 'vue'; + +import { renderThumbStyle, BAR_MAP } from './util'; +import { defineComponent, computed, unref, inject, Ref, reactive, ref, onBeforeUnmount } from 'vue'; +import { on, off } from '/@/utils/domUtils'; + +export default defineComponent({ + name: 'Bar', + props: { + vertical: { + type: Boolean as PropType, + default: false, + }, + size: String as PropType, + move: Number as PropType, + }, + setup(props) { + const thumbRef = ref>(null); + const elRef = ref>(null); + const commonState = reactive({}); + const getBarRef = computed(() => { + return BAR_MAP[props.vertical ? 'vertical' : 'horizontal']; + }); + const parentElRef = inject('scroll-bar-wrap') as Ref>; + + function clickThumbHandler(e: any) { + const { ctrlKey, button, currentTarget } = e; + // prevent click event of right button + if (ctrlKey || button === 2 || !currentTarget) { + return; + } + startDrag(e); + const bar = unref(getBarRef); + commonState[bar.axis] = + currentTarget[bar.offset] - + (e[bar.client as keyof typeof e] - currentTarget.getBoundingClientRect()[bar.direction]); + } + + function clickTrackHandler(e: any) { + const bar = unref(getBarRef); + const offset = Math.abs(e.target.getBoundingClientRect()[bar.direction] - e[bar.client]); + const thumbEl = unref(thumbRef) as any; + const parentEl = unref(parentElRef) as any; + const el = unref(elRef) as any; + if (!thumbEl || !el || !parentEl) return; + const thumbHalf = thumbEl[bar.offset] / 2; + const thumbPositionPercentage = ((offset - thumbHalf) * 100) / el[bar.offset]; + parentEl[bar.scroll] = (thumbPositionPercentage * parentEl[bar.scrollSize]) / 100; + } + + function startDrag(e: Event) { + e.stopImmediatePropagation(); + commonState.cursorDown = true; + + on(document, 'mousemove', mouseMoveDocumentHandler); + on(document, 'mouseup', mouseUpDocumentHandler); + document.onselectstart = () => false; + } + + function mouseMoveDocumentHandler(e: any) { + if (commonState.cursorDown === false) return; + const bar = unref(getBarRef); + const prevPage = commonState[bar.axis]; + const el = unref(elRef) as any; + const parentEl = unref(parentElRef) as any; + const thumbEl = unref(thumbRef) as any; + if (!prevPage || !el || !thumbEl || !parentEl) return; + const rect = el.getBoundingClientRect() as any; + const offset = (rect[bar.direction] - e[bar.client]) * -1; + const thumbClickPosition = thumbEl[bar.offset] - prevPage; + const thumbPositionPercentage = ((offset - thumbClickPosition) * 100) / el[bar.offset]; + + parentEl[bar.scroll] = (thumbPositionPercentage * parentEl[bar.scrollSize]) / 100; + } + + function mouseUpDocumentHandler() { + const bar = unref(getBarRef); + commonState.cursorDown = false; + commonState[bar.axis] = 0; + off(document, 'mousemove', mouseMoveDocumentHandler); + document.onselectstart = null; + } + + onBeforeUnmount(() => { + off(document, 'mouseup', mouseUpDocumentHandler); + }); + return () => { + const bar = unref(getBarRef); + const { size, move } = props; + return ( +
    +
    +
    + ); + }; + }, +}); diff --git a/src/components/Scrollbar/src/Scrollbar.tsx b/src/components/Scrollbar/src/Scrollbar.tsx new file mode 100644 index 000000000..54c4a4a7f --- /dev/null +++ b/src/components/Scrollbar/src/Scrollbar.tsx @@ -0,0 +1,146 @@ +import { addResizeListener, removeResizeListener } from '/@/utils/event/resizeEvent'; +import scrollbarWidth from '/@/utils/scrollbarWidth'; +import { toObject } from './util'; +import Bar from './Bar'; +import { isString } from '/@/utils/is'; +import { + defineComponent, + PropType, + unref, + reactive, + ref, + provide, + onMounted, + nextTick, + onBeforeUnmount, +} from 'vue'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import { tryTsxEmit } from '/@/utils/helper/vueHelper'; +import './index.less'; + +export default defineComponent({ + name: 'Scrollbar', + props: { + native: Boolean as PropType, + wrapStyle: { + type: Object as PropType, + }, + wrapClass: { type: String as PropType, required: false }, + viewClass: { type: String as PropType }, + viewStyle: { type: Object as PropType }, + noresize: Boolean as PropType, + tag: { + type: String as PropType, + default: 'div', + }, + }, + setup(props, { slots }) { + const resizeRef = ref>(null); + const wrapElRef = ref>(null); + provide('scroll-bar-wrap', wrapElRef); + const state = reactive({ + sizeWidth: '0', + sizeHeight: '0', + moveX: 0, + moveY: 0, + }); + + function handleScroll() { + const warpEl = unref(wrapElRef); + if (!warpEl) return; + const { scrollTop, scrollLeft, clientHeight, clientWidth } = warpEl; + + state.moveY = (scrollTop * 100) / clientHeight; + state.moveX = (scrollLeft * 100) / clientWidth; + } + function update() { + const warpEl = unref(wrapElRef); + if (!warpEl) return; + const { scrollHeight, scrollWidth, clientHeight, clientWidth } = warpEl; + const heightPercentage = (clientHeight * 100) / scrollHeight; + const widthPercentage = (clientWidth * 100) / scrollWidth; + + state.sizeHeight = heightPercentage < 100 ? heightPercentage + '%' : ''; + state.sizeWidth = widthPercentage < 100 ? widthPercentage + '%' : ''; + } + + onMounted(() => { + tryTsxEmit((instance) => { + instance.wrap = unref(wrapElRef); + }); + + const { native, noresize } = props; + const resizeEl = unref(resizeRef); + const warpEl = unref(wrapElRef); + if (native || !resizeEl || !warpEl) return; + nextTick(update); + if (!noresize) { + addResizeListener(resizeEl, update); + addResizeListener(warpEl, update); + } + }); + onBeforeUnmount(() => { + const { native, noresize } = props; + const resizeEl = unref(resizeRef); + const warpEl = unref(wrapElRef); + if (native || !resizeEl || !warpEl) return; + if (!noresize) { + removeResizeListener(resizeEl, update); + removeResizeListener(warpEl, update); + } + }); + return () => { + const { native, tag, viewClass, viewStyle, wrapClass, wrapStyle } = props; + let style: any = wrapStyle; + const gutter = scrollbarWidth(); + + if (gutter) { + const gutterWith = `-${gutter}px`; + const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`; + + if (Array.isArray(wrapStyle)) { + style = toObject(wrapStyle); + style.marginRight = style.marginBottom = gutterWith; + } else if (isString(wrapStyle)) { + style += gutterStyle; + } else { + style = gutterStyle; + } + } + + const Tag = tag as any; + const view = ( + + {getSlot(slots)} + + ); + const wrap = ( +
    + {[view]} +
    + ); + let nodes: any[] = []; + const { moveX, sizeWidth, moveY, sizeHeight } = state; + if (!native) { + nodes = [ + wrap, + /* eslint-disable */ + , + , + ]; + } else { + nodes = [ +
    + {[view]} +
    , + ]; + } + return
    {nodes}
    ; + }; + }, +}); diff --git a/src/components/Scrollbar/src/index.less b/src/components/Scrollbar/src/index.less new file mode 100644 index 000000000..8f688074e --- /dev/null +++ b/src/components/Scrollbar/src/index.less @@ -0,0 +1,69 @@ +.scrollbar { + position: relative; + overflow: hidden; + + &__wrap { + height: 100%; + overflow: scroll; + + &--hidden-default { + scrollbar-width: none; + + &::-webkit-scrollbar { + width: 0; + height: 0; + } + } + } + + &__thumb { + position: relative; + display: block; + width: 0; + height: 0; + cursor: pointer; + background-color: rgba(144, 147, 153, 0.3); + border-radius: inherit; + transition: 0.3s background-color; + + &:hover { + background-color: rgba(144, 147, 153, 0.5); + } + } + + &__bar { + position: absolute; + right: 2px; + bottom: 2px; + z-index: 1; + border-radius: 4px; + opacity: 0; + -webkit-transition: opacity 120ms ease-out; + transition: opacity 120ms ease-out; + + &.is-vertical { + top: 2px; + width: 6px; + + & > div { + width: 100%; + } + } + + &.is-horizontal { + left: 2px; + height: 6px; + + & > div { + height: 100%; + } + } + } +} + +.scrollbar:active > .scrollbar__bar, +.scrollbar:focus > .scrollbar__bar, +.scrollbar:hover > .scrollbar__bar { + opacity: 1; + transition: opacity 280ms ease-out; +} diff --git a/src/components/Scrollbar/src/types.d.ts b/src/components/Scrollbar/src/types.d.ts new file mode 100644 index 000000000..1216a65d7 --- /dev/null +++ b/src/components/Scrollbar/src/types.d.ts @@ -0,0 +1,14 @@ +export interface BarMapItem { + offset: string; + scroll: string; + scrollSize: string; + size: string; + key: string; + axis: string; + client: string; + direction: string; +} +export interface BarMap { + vertical: BarMapItem; + horizontal: BarMapItem; +} diff --git a/src/components/Scrollbar/src/util.ts b/src/components/Scrollbar/src/util.ts new file mode 100644 index 000000000..8b5e1c5b1 --- /dev/null +++ b/src/components/Scrollbar/src/util.ts @@ -0,0 +1,49 @@ +import type { BarMap } from './types'; +export const BAR_MAP: BarMap = { + vertical: { + offset: 'offsetHeight', + scroll: 'scrollTop', + scrollSize: 'scrollHeight', + size: 'height', + key: 'vertical', + axis: 'Y', + client: 'clientY', + direction: 'top', + }, + horizontal: { + offset: 'offsetWidth', + scroll: 'scrollLeft', + scrollSize: 'scrollWidth', + size: 'width', + key: 'horizontal', + axis: 'X', + client: 'clientX', + direction: 'left', + }, +}; + +export function renderThumbStyle({ move, size, bar }) { + const style = {} as any; + const translate = `translate${bar.axis}(${move}%)`; + + style[bar.size] = size; + style.transform = translate; + style.msTransform = translate; + style.webkitTransform = translate; + + return style; +} + +function extend(to: T, _from: K): T & K { + return Object.assign(to, _from); +} + +export function toObject(arr: Array): Record { + const res = {}; + for (let i = 0; i < arr.length; i++) { + if (arr[i]) { + extend(res, arr[i]); + } + } + return res; +} diff --git a/src/components/StrengthMeter/index.less b/src/components/StrengthMeter/index.less new file mode 100644 index 000000000..9bd384029 --- /dev/null +++ b/src/components/StrengthMeter/index.less @@ -0,0 +1,69 @@ +@import (reference) '../../design/index.less'; + +.streng-meter { + position: relative; + + &-bar { + position: relative; + height: 4px; + margin: 10px auto 6px; + background: @disabled-color; + border-radius: 3px; + + &::before, + &::after { + position: absolute; + z-index: 10; + display: block; + width: 20%; + height: inherit; + background: transparent; + border-color: @white; + border-style: solid; + border-width: 0 5px 0 5px; + content: ''; + } + + &::before { + left: 20%; + } + + &::after { + right: 20%; + } + + &__fill { + position: absolute; + width: 0; + height: inherit; + background: transparent; + border-radius: inherit; + transition: width 0.5s ease-in-out, background 0.25s; + + &[data-score='0'] { + width: 20%; + background: darken(@error-color, 10%); + } + + &[data-score='1'] { + width: 40%; + background: @error-color; + } + + &[data-score='2'] { + width: 60%; + background: @warning-color; + } + + &[data-score='3'] { + width: 80%; + background: fade(@success-color, 50%); + } + + &[data-score='4'] { + width: 100%; + background: @success-color; + } + } + } +} diff --git a/src/components/StrengthMeter/index.tsx b/src/components/StrengthMeter/index.tsx new file mode 100644 index 000000000..dece8e0c9 --- /dev/null +++ b/src/components/StrengthMeter/index.tsx @@ -0,0 +1,83 @@ +import { PropType } from 'vue'; + +import { defineComponent, computed, ref, watch, unref, watchEffect } from 'vue'; + +import { Input } from 'ant-design-vue'; + +import zxcvbn from 'zxcvbn'; +import { extendSlots } from '/@/utils/helper/tsxHelper'; +import './index.less'; +const prefixCls = 'streng-meter'; +export default defineComponent({ + name: 'StrengMeter', + emits: ['score-change', 'change'], + props: { + value: { + type: String as PropType, + default: undefined, + }, + + userInputs: { + type: Array as PropType, + default: () => [], + }, + + showInput: { + type: Boolean as PropType, + default: true, + }, + disabled: { + type: Boolean as PropType, + default: false, + }, + }, + setup(props, { emit, attrs, slots }) { + const innerValueRef = ref(''); + const getPasswordStrength = computed(() => { + const { userInputs, disabled } = props; + if (disabled) return null; + const innerValue = unref(innerValueRef); + const score = innerValue + ? zxcvbn(unref(innerValueRef), (userInputs as string[]) || null).score + : null; + emit('score-change', score); + return score; + }); + + function handleChange(e: ChangeEvent) { + innerValueRef.value = e.target.value; + } + + watchEffect(() => { + innerValueRef.value = props.value || ''; + }); + watch( + () => unref(innerValueRef), + (val) => { + emit('change', val); + } + ); + + return () => { + const { showInput, disabled } = props; + return ( +
    + {showInput && ( + + {extendSlots(slots)} + + )} +
    +
    +
    +
    + ); + }; + }, +}); diff --git a/src/components/Transition/index.ts b/src/components/Transition/index.ts new file mode 100644 index 000000000..c7f489a0d --- /dev/null +++ b/src/components/Transition/index.ts @@ -0,0 +1,28 @@ +import { createSimpleTransition, createJavascriptTransition } from './src/CreateTransition'; + +import ExpandTransitionGenerator from './src/ExpandTransition'; + +export { default as CollapseTransition } from './src/CollapseTransition'; + +export const FadeTransition = createSimpleTransition('fade-transition'); +export const ScaleTransition = createSimpleTransition('scale-transition'); +export const SlideYTransition = createSimpleTransition('slide-y-transition'); +export const ScrollYTransition = createSimpleTransition('scroll-y-transition'); +export const SlideYReverseTransition = createSimpleTransition('slide-y-reverse-transition'); +export const ScrollYReverseTransition = createSimpleTransition('scroll-y-reverse-transition'); +export const SlideXTransition = createSimpleTransition('slide-x-transition'); +export const ScrollXTransition = createSimpleTransition('scroll-x-transition'); +export const SlideXReverseTransition = createSimpleTransition('slide-x-reverse-transition'); +export const ScrollXReverseTransition = createSimpleTransition('scroll-x-reverse-transition'); +export const ScaleRotateTransition = createSimpleTransition('scale-rotate-transition'); + +// Javascript transitions +export const ExpandTransition = createJavascriptTransition( + 'expand-transition', + ExpandTransitionGenerator() +); + +export const ExpandXTransition = createJavascriptTransition( + 'expand-x-transition', + ExpandTransitionGenerator('', true) +); diff --git a/src/components/Transition/src/CollapseTransition.tsx b/src/components/Transition/src/CollapseTransition.tsx new file mode 100644 index 000000000..949c43b42 --- /dev/null +++ b/src/components/Transition/src/CollapseTransition.tsx @@ -0,0 +1,29 @@ +import type { PropType } from 'vue'; + +// collapse 展开折叠 +import { defineComponent } from 'vue'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import { createJavascriptTransition } from './CreateTransition'; +import ExpandTransitionGenerator from './ExpandTransition'; + +export const ExpandTransition = createJavascriptTransition( + 'expand-transition', + ExpandTransitionGenerator() +); +export default defineComponent({ + name: 'CollapseTransition', + components: { + ExpandTransition, + }, + props: { + // 是否打开折叠功能 + enable: { + type: Boolean as PropType, + default: true, + }, + }, + setup(props, { slots }) { + return () => + props.enable ? {() => getSlot(slots)} : getSlot(slots); + }, +}); diff --git a/src/components/Transition/src/CreateTransition.tsx b/src/components/Transition/src/CreateTransition.tsx new file mode 100644 index 000000000..916243706 --- /dev/null +++ b/src/components/Transition/src/CreateTransition.tsx @@ -0,0 +1,81 @@ +import type { PropType } from 'vue'; + +import { defineComponent, Transition, TransitionGroup } from 'vue'; +import { getSlot } from '/@/utils/helper/tsxHelper'; + +type Mode = 'in-out' | 'out-in' | 'default' | undefined; + +export function createSimpleTransition(name: string, origin = 'top center 0', mode?: Mode) { + return defineComponent({ + name, + props: { + group: { + type: Boolean as PropType, + default: false, + }, + // hideOnLeave: { + // type: Boolean as PropType, + // default: false, + // }, + // leaveAbsolute: { + // type: Boolean as PropType, + // default: false, + // }, + mode: { + type: String as PropType, + default: mode, + }, + origin: { + type: String as PropType, + default: origin, + }, + }, + setup(props, { slots, attrs }) { + const onBeforeEnter = (el: HTMLElement) => { + el.style.transformOrigin = props.origin; + }; + + return () => { + const Tag = !props.group ? Transition : TransitionGroup; + return ( + + {() => getSlot(slots)} + + ); + }; + }, + }); +} +export function createJavascriptTransition( + name: string, + functions: Record, + mode: Mode = 'in-out' +) { + return defineComponent({ + name, + props: { + mode: { + type: String as PropType, + default: mode, + }, + }, + setup(props, { attrs, slots }) { + return () => { + return ( + + {() => getSlot(slots)} + + ); + }; + }, + }); +} diff --git a/src/components/Transition/src/ExpandTransition.ts b/src/components/Transition/src/ExpandTransition.ts new file mode 100644 index 000000000..88f02e390 --- /dev/null +++ b/src/components/Transition/src/ExpandTransition.ts @@ -0,0 +1,93 @@ +/** + * Makes the first character of a string uppercase + */ +export function upperFirst(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +interface HTMLExpandElement extends HTMLElement { + _parent?: (Node & ParentNode & HTMLElement) | null; + _initialStyle: { + transition: string; + visibility: string | null; + overflow: string | null; + height?: string | null; + width?: string | null; + }; +} + +export default function (expandedParentClass = '', x = false) { + const sizeProperty = x ? 'width' : ('height' as 'width' | 'height'); + const offsetProperty = `offset${upperFirst(sizeProperty)}` as 'offsetHeight' | 'offsetWidth'; + + return { + beforeEnter(el: HTMLExpandElement) { + el._parent = el.parentNode as (Node & ParentNode & HTMLElement) | null; + el._initialStyle = { + transition: el.style.transition, + visibility: el.style.visibility, + overflow: el.style.overflow, + [sizeProperty]: el.style[sizeProperty], + }; + }, + + enter(el: HTMLExpandElement) { + const initialStyle = el._initialStyle; + const offset = `${el[offsetProperty]}px`; + + el.style.setProperty('transition', 'none', 'important'); + el.style.visibility = 'hidden'; + el.style.visibility = initialStyle.visibility!; + el.style.overflow = 'hidden'; + el.style[sizeProperty] = '0'; + /* eslint-disable-next-line */ + void el.offsetHeight; // force reflow + + el.style.transition = initialStyle.transition; + + if (expandedParentClass && el._parent) { + el._parent.classList.add(expandedParentClass); + } + + requestAnimationFrame(() => { + el.style[sizeProperty] = offset; + }); + }, + + afterEnter: resetStyles, + enterCancelled: resetStyles, + + leave(el: HTMLExpandElement) { + el._initialStyle = { + transition: '', + visibility: '', + overflow: el.style.overflow, + [sizeProperty]: el.style[sizeProperty], + }; + + el.style.overflow = 'hidden'; + el.style[sizeProperty] = `${el[offsetProperty]}px`; + /* eslint-disable-next-line */ + void el.offsetHeight; // force reflow + + requestAnimationFrame(() => (el.style[sizeProperty] = '0')); + }, + + afterLeave, + leaveCancelled: afterLeave, + }; + + function afterLeave(el: HTMLExpandElement) { + if (expandedParentClass && el._parent) { + el._parent.classList.remove(expandedParentClass); + } + resetStyles(el); + } + + function resetStyles(el: HTMLExpandElement) { + const size = el._initialStyle[sizeProperty]; + el.style.overflow = el._initialStyle.overflow!; + if (size != null) el.style[sizeProperty] = size; + Reflect.deleteProperty(el, '_initialStyle'); + } +} diff --git a/src/components/Tree/index.ts b/src/components/Tree/index.ts new file mode 100644 index 000000000..574d5ea28 --- /dev/null +++ b/src/components/Tree/index.ts @@ -0,0 +1,3 @@ +export { default as BasicTree } from './src/BasicTree'; +export * from './src/types'; +export type { ContextMenuItem } from '/@/hooks/web/useContextMenu'; diff --git a/src/components/Tree/src/BasicTree.tsx b/src/components/Tree/src/BasicTree.tsx new file mode 100644 index 000000000..10b1b113c --- /dev/null +++ b/src/components/Tree/src/BasicTree.tsx @@ -0,0 +1,270 @@ +import { defineComponent, reactive, computed, unref, ref, watchEffect } from 'vue'; +import { Tree } from 'ant-design-vue'; +import { extendSlots } from '/@/utils/helper/tsxHelper'; +import { useContextMenu, ContextMenuItem } from '/@/hooks/web/useContextMenu'; +import { basicProps } from './props'; +import { isFunction } from '/@/utils/is'; +import { omit } from 'lodash-es'; +import { DownOutlined } from '@ant-design/icons-vue'; + +import type { ReplaceFields, TreeItem, Keys, CheckKeys, InsertNodeParams } from './types'; +import { tryTsxEmit } from '/@/utils/helper/vueHelper'; + +import './index.less'; +import { forEach } from '/@/utils/helper/treeHelper'; +import { cloneDeep } from 'lodash-es'; + +interface State { + expandedKeys: Keys; + selectedKeys: Keys; + checkedKeys: CheckKeys; +} +const prefixCls = 'basic-tree'; +export default defineComponent({ + name: 'BasicTree', + props: basicProps, + emits: ['update:expandedKeys', 'update:selectedKeys', 'update:value'], + setup(props, { attrs, slots, emit }) { + const state = reactive({ + expandedKeys: props.expandedKeys || [], + selectedKeys: props.selectedKeys || [], + checkedKeys: props.checkedKeys || [], + }); + + const treeDataRef = ref([]); + + const [createContextMenu] = useContextMenu(); + + const getReplaceFields = computed( + (): Required => { + const { replaceFields } = props; + return { + children: 'children', + title: 'title', + key: 'key', + ...replaceFields, + }; + } + ); + + const getTreeData = computed(() => { + return unref(treeDataRef); + }); + + // 渲染操作按钮 + function renderAction(node: TreeItem) { + const { actionList } = props; + + if (!actionList || actionList.length === 0) { + return; + } + + return actionList.map((item, index) => { + return ( + + {item.render(node)} + + ); + }); + } + // 渲染树节点 + function renderTreeNode({ data }: { data: TreeItem[] | undefined }) { + if (!data) { + return null; + } + + return data.map((item) => { + const { title: titleField, key: keyField, children: childrenField } = unref( + getReplaceFields + ); + const propsData = omit(item, 'title'); + const anyItem = item as any; + return ( + + {{ + title: () => ( + + {titleField && anyItem[titleField]} + {renderAction(item)} + + ), + default: () => renderTreeNode({ data: childrenField ? anyItem[childrenField] : [] }), + }} + + ); + }); + } + // 处理右键事件 + async function handleRightClick({ event, node }: any) { + const { rightMenuList: menuList = [], beforeRightClick } = props; + let rightMenuList: ContextMenuItem[] = []; + if (beforeRightClick && isFunction(beforeRightClick)) { + rightMenuList = await beforeRightClick(node); + } else { + rightMenuList = menuList; + } + if (!rightMenuList.length) return; + createContextMenu({ + event, + items: rightMenuList, + }); + } + + function setExpandedKeys(keys: string[]) { + state.expandedKeys = keys; + } + + function getExpandedKeys() { + return state.expandedKeys; + } + function setSelectedKeys(keys: string[]) { + state.selectedKeys = keys; + } + + function getSelectedKeys() { + return state.selectedKeys; + } + + function setCheckedKeys(keys: CheckKeys) { + state.checkedKeys = keys; + } + + function getCheckedKeys() { + return state.checkedKeys; + } + + // 展开指定级别 + function filterByLevel(level = 1, list?: TreeItem[], currentLevel = 1) { + if (!level) { + return []; + } + const res: (string | number)[] = []; + const data = list || props.treeData || []; + for (let index = 0; index < data.length; index++) { + const item = data[index] as any; + + const { key: keyField, children: childrenField } = unref(getReplaceFields); + const key = keyField ? item[keyField] : ''; + const children = childrenField ? item[childrenField] : []; + res.push(key); + if (children && children.length && currentLevel < level) { + currentLevel += 1; + res.push(...filterByLevel(level, children, currentLevel)); + } + } + return res as string[] | number[]; + } + /** + * 添加节点 + */ + function insertNodeByKey({ parentKey = null, node, push = 'push' }: InsertNodeParams) { + const treeData: any = cloneDeep(unref(treeDataRef)); + if (!parentKey) { + treeData[push](node); + return; + } + const { key: keyField, children: childrenField } = unref(getReplaceFields); + forEach(treeData, (treeItem) => { + if (treeItem[keyField] === parentKey) { + treeItem[childrenField] = treeItem[childrenField] || []; + treeItem[childrenField][push](node); + } + }); + treeDataRef.value = treeData; + } + + // 删除节点 + function deleteNodeByKey(key: string, list: TreeItem[]) { + if (!key) return; + const treeData = list || unref(treeDataRef); + const { key: keyField, children: childrenField } = unref(getReplaceFields); + + for (let index = 0; index < treeData.length; index++) { + const element: any = treeData[index]; + const children = element[childrenField]; + + if (element[keyField] === key) { + treeData.splice(index, 1); + break; + } else if (children && children.length) { + deleteNodeByKey(key, element[childrenField]); + } + } + } + + // 更新节点 + function updateNodeByKey(key: string, node: TreeItem, list: TreeItem[]) { + if (!key) return; + const treeData = list || unref(treeDataRef); + const { key: keyField, children: childrenField } = unref(getReplaceFields); + + for (let index = 0; index < treeData.length; index++) { + const element: any = treeData[index]; + const children = element[childrenField]; + + if (element[keyField] === key) { + treeData[index] = { ...treeData[index], ...node }; + break; + } else if (children && children.length) { + updateNodeByKey(key, node, element[childrenField]); + } + } + } + + watchEffect(() => { + treeDataRef.value = props.treeData as TreeItem[]; + state.expandedKeys = props.expandedKeys; + state.selectedKeys = props.selectedKeys; + state.checkedKeys = props.checkedKeys; + }); + + tryTsxEmit((currentInstance) => { + currentInstance.setExpandedKeys = setExpandedKeys; + currentInstance.getExpandedKeys = getExpandedKeys; + currentInstance.setSelectedKeys = setSelectedKeys; + currentInstance.getSelectedKeys = getSelectedKeys; + currentInstance.setCheckedKeys = setCheckedKeys; + currentInstance.getCheckedKeys = getCheckedKeys; + currentInstance.insertNodeByKey = insertNodeByKey; + currentInstance.deleteNodeByKey = deleteNodeByKey; + currentInstance.updateNodeByKey = updateNodeByKey; + currentInstance.filterByLevel = (level: number) => { + state.expandedKeys = filterByLevel(level); + }; + }); + return () => { + let propsData: any = { + blockNode: true, + ...attrs, + ...props, + expandedKeys: state.expandedKeys, + selectedKeys: state.selectedKeys, + checkedKeys: state.checkedKeys, + replaceFields: unref(getReplaceFields), + 'onUpdate:expandedKeys': (v: Keys) => { + state.expandedKeys = v; + emit('update:expandedKeys', v); + }, + 'onUpdate:selectedKeys': (v: Keys) => { + state.selectedKeys = v; + emit('update:selectedKeys', v); + }, + check: (v: CheckKeys) => { + state.checkedKeys = v; + emit('update:value', v); + }, + onRightClick: handleRightClick, + }; + propsData = omit(propsData, 'treeData'); + return ( + + {{ + switcherIcon: () => , + default: () => renderTreeNode({ data: unref(getTreeData) }), + ...extendSlots(slots), + }} + + ); + }; + }, +}); diff --git a/src/components/Tree/src/index.less b/src/components/Tree/src/index.less new file mode 100644 index 000000000..69a04d1b3 --- /dev/null +++ b/src/components/Tree/src/index.less @@ -0,0 +1,20 @@ +.basic-tree { + position: relative; + + &-title { + display: inline-block; + width: 100%; + padding-right: 10px; + + .basic-tree__action { + display: none; + float: right; + } + + &:hover { + .basic-tree__action { + display: inline-block; + } + } + } +} diff --git a/src/components/Tree/src/props.ts b/src/components/Tree/src/props.ts new file mode 100644 index 000000000..0a924ab14 --- /dev/null +++ b/src/components/Tree/src/props.ts @@ -0,0 +1,56 @@ +import { PropType } from 'vue'; +import type { ReplaceFields, TreeItem, ActionItem, Keys, CheckKeys } from './types'; +import type { ContextMenuItem } from '/@/hooks/web/useContextMenu'; + +export const basicProps = { + replaceFields: { + type: Object as PropType, + }, + + treeData: { + type: Array as PropType, + }, + + actionList: { + type: Array as PropType, + default: () => [], + }, + + expandedKeys: { + type: Array as PropType, + default: () => [], + }, + + selectedKeys: { + type: Array as PropType, + default: () => [], + }, + + checkedKeys: { + type: Array as PropType, + default: () => [], + }, + + beforeRightClick: { + type: Function as PropType<(...arg: any) => ContextMenuItem[]>, + default: null, + }, + + rightMenuList: { + type: Array as PropType, + }, +}; + +export const treeNodeProps = { + actionList: { + type: Array as PropType, + default: () => [], + }, + replaceFields: { + type: Object as PropType, + }, + treeData: { + type: Array as PropType, + default: () => [], + }, +}; diff --git a/src/components/Tree/src/types.ts b/src/components/Tree/src/types.ts new file mode 100644 index 000000000..8fae9fa3c --- /dev/null +++ b/src/components/Tree/src/types.ts @@ -0,0 +1,118 @@ +export interface ActionItem { + render: (record: any) => any; +} + +export interface TreeItem { + /** + * Class + * @description className + * @type string + */ + class?: string; + + /** + * Style + * @description style of tree node + * @type string | object + */ + style?: string | object; + + /** + * Disable Checkbox + * @description Disables the checkbox of the treeNode + * @default false + * @type boolean + */ + disableCheckbox?: boolean; + + /** + * Disabled + * @description Disabled or not + * @default false + * @type boolean + */ + disabled?: boolean; + + /** + * Icon + * @description customize icon. When you pass component, whose render will receive full TreeNode props as component props + * @type any (slot | slot-scope) + */ + icon?: any; + + /** + * Is Leaf? + * @description Leaf node or not + * @default false + * @type boolean + */ + isLeaf?: boolean; + + /** + * Key + * @description Required property, should be unique in the tree + * (In tree: Used with (default)ExpandedKeys / (default)CheckedKeys / (default)SelectedKeys) + * @default internal calculated position of treeNode or undefined + * @type string | number + */ + key: string | number; + + /** + * Selectable + * @description Set whether the treeNode can be selected + * @default true + * @type boolean + */ + selectable?: boolean; + + /** + * Title + * @description Content showed on the treeNodes + * @default '---' + * @type any (string | slot) + */ + title: any; + + /** + * Value + * @description Will be treated as treeNodeFilterProp by default, should be unique in the tree + * @default undefined + * @type string + */ + value?: string; + children?: TreeItem[]; + slots?: any; + scopedSlots?: any; +} + +export interface ReplaceFields { + children?: string; + title?: string; + key?: string; +} + +export type Keys = string[] | number[]; +export type CheckKeys = + | string[] + | number[] + | { checked: string[] | number[]; halfChecked: string[] | number[] }; + +export interface TreeActionType { + setExpandedKeys: (keys: Keys) => void; + getExpandedKeys: () => Keys; + setSelectedKeys: (keys: Keys) => void; + getSelectedKeys: () => Keys; + setCheckedKeys: (keys: CheckKeys) => void; + getCheckedKeys: () => CheckKeys; + filterByLevel: (level: number) => void; + insertNodeByKey: (opt: InsertNodeParams) => void; + deleteNodeByKey: (key: string) => void; + updateNodeByKey: (key: string, node: Omit) => void; +} + +export interface InsertNodeParams { + parentKey: string | null; + node: TreeItem; + list?: TreeItem[]; + push?: 'push' | 'unshift'; +} diff --git a/src/components/Verify/index.ts b/src/components/Verify/index.ts new file mode 100644 index 000000000..0fe28a390 --- /dev/null +++ b/src/components/Verify/index.ts @@ -0,0 +1,4 @@ +export { default as BasicDragVerify } from './src/DragVerify'; +export { default as RotateDragVerify } from './src/ImgRotate'; + +export * from './src/types'; diff --git a/src/components/Verify/src/DragVerify.less b/src/components/Verify/src/DragVerify.less new file mode 100644 index 000000000..0bf2bda84 --- /dev/null +++ b/src/components/Verify/src/DragVerify.less @@ -0,0 +1,89 @@ +@import (reference) '../../../design/index.less'; + +@radius: 4px; + +.darg-verify { + position: relative; + overflow: hidden; + text-align: center; + background-color: rgb(238, 238, 238); + border: 1px solid #ddd; + border-radius: @radius; + + &-bar { + position: absolute; + width: 0; + height: 36px; + background: @success-color; + border-radius: @radius; + + &.to-left { + width: 0 !important; + transition: width 0.3s; + } + } + + &-content { + position: absolute; + top: 0; + font-size: 12px; + -webkit-text-size-adjust: none; + background: -webkit-gradient( + linear, + left top, + right top, + color-stop(0, #333), + color-stop(0.4, #333), + color-stop(0.5, #fff), + color-stop(0.6, #333), + color-stop(1, #333) + ); + animation: slidetounlock 3s infinite; + -webkit-background-clip: text; + -moz-user-select: none; + -webkit-user-select: none; + -o-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-text-fill-color: transparent; + + &.success { + -webkit-text-fill-color: @white; + } + + & > * { + -webkit-text-fill-color: #333; + } + } + + &-action { + position: absolute; + top: 0; + left: 0; + display: flex; + cursor: move; + background: @white; + border-radius: @radius; + justify-content: center; + align-items: center; + + &__icon { + cursor: inherit; + } + + &.to-left { + left: 0 !important; + transition: left 0.3s; + } + } +} + +@-webkit-keyframes slidetounlock { + 0% { + background-position: -120px 0; + } + + 100% { + background-position: 120px 0; + } +} diff --git a/src/components/Verify/src/DragVerify.tsx b/src/components/Verify/src/DragVerify.tsx new file mode 100644 index 000000000..98725d1e4 --- /dev/null +++ b/src/components/Verify/src/DragVerify.tsx @@ -0,0 +1,282 @@ +import { defineComponent, ref, computed, unref, reactive, watch, watchEffect } from 'vue'; +import { useTimeout } from '/@/hooks/core/useTimeout'; +import { useEvent } from '/@/hooks/event/useEvent'; +import { basicProps } from './props'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import './DragVerify.less'; +import { CheckOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'; +import { tryTsxEmit } from '/@/utils/helper/vueHelper'; +export default defineComponent({ + name: 'BaseDargVerify', + props: basicProps, + emits: ['success', 'update:value', 'change', 'start', 'move', 'end'], + setup(props, { emit, slots }) { + const state = reactive({ + isMoving: false, + isPassing: false, + moveDistance: 0, + toLeft: false, + startTime: 0, + endTime: 0, + }); + + const wrapElRef = ref(null); + const barElRef = ref(null); + const contentElRef = ref(null); + const actionElRef = ref(null); + + watch( + () => state.isPassing, + (isPassing) => { + if (isPassing) { + const { startTime, endTime } = state; + const time = (endTime - startTime) / 1000; + emit('success', { isPassing, time: time.toFixed(1) }); + emit('update:value', isPassing); + emit('change', isPassing); + } + } + ); + + watchEffect(() => { + state.isPassing = !!props.value; + }); + + const getActionStyleRef = computed(() => { + const { height, actionStyle } = props; + const h = `${parseInt(height as string)}px`; + return { + left: 0, + width: h, + height: h, + ...actionStyle, + }; + }); + const getWrapStyleRef = computed(() => { + const { height, width, circle, wrapStyle } = props; + const h = parseInt(height as string); + const w = `${parseInt(width as string)}px`; + return { + width: w, + height: `${h}px`, + lineHeight: `${h}px`, + borderRadius: circle ? h / 2 + 'px' : 0, + ...wrapStyle, + }; + }); + + const getBarStyleRef = computed(() => { + const { height, circle, barStyle } = props; + const h = parseInt(height as string); + return { + height: `${h}px`, + borderRadius: circle ? h / 2 + 'px 0 0 ' + h / 2 + 'px' : 0, + ...barStyle, + }; + }); + + const getContentStyleRef = computed(() => { + const { height, width, contentStyle } = props; + const h = `${parseInt(height as string)}px`; + const w = `${parseInt(width as string)}px`; + + return { + height: h, + width: w, + ...contentStyle, + }; + }); + + function getEventPageX(e: MouseEvent | TouchEvent) { + return (e as MouseEvent).pageX || (e as TouchEvent).touches[0].pageX; + } + + useEvent({ + el: document, + name: 'mouseup', + listener: () => { + if (state.isMoving) { + resume(); + } + }, + }); + function handleDragStart(e: MouseEvent | TouchEvent) { + if (state.isPassing) { + return; + } + + const actionEl = unref(actionElRef); + if (!actionEl) return; + emit('start', e); + state.moveDistance = getEventPageX(e) - parseInt(actionEl.style.left.replace('px', ''), 10); + state.startTime = new Date().getTime(); + state.isMoving = true; + } + function getOffset(el: HTMLDivElement) { + const actionWidth = parseInt(el.style.width); + const { width } = props; + const widthNum = parseInt(width as string); + const offset = widthNum - actionWidth - 6; + return { offset, widthNum, actionWidth }; + } + function handleDragMoving(e: MouseEvent | TouchEvent) { + const { isMoving, moveDistance } = state; + if (isMoving) { + const actionEl = unref(actionElRef); + const barEl = unref(barElRef); + if (!actionEl || !barEl) return; + const { offset, widthNum, actionWidth } = getOffset(actionEl); + const moveX = getEventPageX(e) - moveDistance; + + emit('move', { + event: e, + moveDistance, + moveX, + }); + if (moveX > 0 && moveX <= offset) { + actionEl.style.left = `${moveX}px`; + barEl.style.width = `${moveX + actionWidth / 2}px`; + } else if (moveX > offset) { + actionEl.style.left = `${widthNum - actionWidth}px`; + barEl.style.width = `${widthNum - actionWidth / 2}px`; + if (!props.isSlot) { + checkPass(); + } + } + } + } + + function handleDragOver(e: MouseEvent | TouchEvent) { + const { isMoving, isPassing, moveDistance } = state; + if (isMoving && !isPassing) { + emit('end', e); + const actionEl = unref(actionElRef); + const barEl = unref(barElRef); + if (!actionEl || !barEl) return; + const moveX = getEventPageX(e) - moveDistance; + const { offset, widthNum, actionWidth } = getOffset(actionEl); + if (moveX < offset) { + if (!props.isSlot) { + resume(); + } else { + setTimeout(() => { + if (!props.value) { + resume(); + } else { + const contentEl = unref(contentElRef); + if (contentEl) { + contentEl.style.width = `${parseInt(barEl.style.width)}px`; + } + } + }, 0); + } + } else { + actionEl.style.left = `${widthNum - actionWidth}px`; + barEl.style.width = `${widthNum - actionWidth / 2}px`; + checkPass(); + } + state.isMoving = false; + } + } + + function checkPass() { + if (props.isSlot) { + resume(); + return; + } + state.endTime = new Date().getTime(); + state.isPassing = true; + state.isMoving = false; + } + + function resume() { + state.isMoving = false; + state.isPassing = false; + state.moveDistance = 0; + state.toLeft = false; + state.startTime = 0; + state.endTime = 0; + const actionEl = unref(actionElRef); + const barEl = unref(barElRef); + const contentEl = unref(contentElRef); + if (!actionEl || !barEl || !contentEl) return; + state.toLeft = true; + useTimeout(() => { + state.toLeft = false; + actionEl.style.left = '0'; + barEl.style.width = '0'; + // The time is consistent with the animation time + }, 300); + contentEl.style.width = unref(getContentStyleRef).width; + } + + tryTsxEmit((instance) => { + instance.resume = resume; + }); + + return () => { + const renderBar = () => { + const cls = [`darg-verify-bar`]; + if (state.toLeft) { + cls.push('to-left'); + } + return
    ; + }; + + const renderContent = () => { + const cls = [`darg-verify-content`]; + const { isPassing } = state; + const { text, successText } = props; + + isPassing && cls.push('success'); + + return ( +
    + {getSlot(slots, 'text', isPassing) || (isPassing ? successText : text)} +
    + ); + }; + + const renderAction = () => { + const cls = [`darg-verify-action`]; + const { toLeft, isPassing } = state; + if (toLeft) { + cls.push('to-left'); + } + return ( +
    + {getSlot(slots, 'actionIcon', isPassing) || + (isPassing ? ( + + ) : ( + + ))} +
    + ); + }; + + return ( +
    + {renderBar()} + {renderContent()} + {renderAction()} +
    + ); + }; + }, +}); diff --git a/src/components/Verify/src/ImgRotate.less b/src/components/Verify/src/ImgRotate.less new file mode 100644 index 000000000..95fa73bb4 --- /dev/null +++ b/src/components/Verify/src/ImgRotate.less @@ -0,0 +1,53 @@ +@import (reference) '../../../design/index.less'; + +.ir-dv { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + + &-img__wrap { + position: relative; + overflow: hidden; + border-radius: 50%; + + img { + width: 100%; + border-radius: 50%; + + &.to-origin { + transition: transform 0.3s; + } + } + } + + &-img__tip { + position: absolute; + bottom: 10px; + left: 0; + z-index: 1; + display: block; + width: 100%; + height: 30px; + font-size: 12px; + line-height: 30px; + color: @white; + text-align: center; + + &.success { + background: fade(@success-color, 60%); + } + + &.error { + background: fade(@error-color, 60%); + } + + &.normal { + background: rgba(0, 0, 0, 0.3); + } + } + + &-drag__bar { + margin-top: 20px; + } +} diff --git a/src/components/Verify/src/ImgRotate.tsx b/src/components/Verify/src/ImgRotate.tsx new file mode 100644 index 000000000..05e2c856b --- /dev/null +++ b/src/components/Verify/src/ImgRotate.tsx @@ -0,0 +1,166 @@ +import type { MoveData, DragVerifyActionType } from './types'; + +import { defineComponent, computed, unref, reactive, watch, ref, getCurrentInstance } from 'vue'; +import { useTimeout } from '/@/hooks/core/useTimeout'; + +import BasicDragVerify from './DragVerify'; + +import { hackCss } from '/@/utils/domUtils'; + +import { rotateProps } from './props'; +import './ImgRotate.less'; +export default defineComponent({ + name: 'ImgRotateDargVerify', + inheritAttrs: false, + props: rotateProps, + emits: ['success', 'change', 'update:value'], + setup(props, { emit, attrs }) { + const basicRef = ref>(null); + const state = reactive({ + showTip: false, + isPassing: false, + imgStyle: {}, + randomRotate: 0, + currentRotate: 0, + toOrigin: false, + startTime: 0, + endTime: 0, + draged: false, + }); + + watch( + () => state.isPassing, + (isPassing) => { + if (isPassing) { + const { startTime, endTime } = state; + const time = (endTime - startTime) / 1000; + emit('success', { isPassing, time: time.toFixed(1) }); + emit('change', isPassing); + emit('update:value', isPassing); + } + } + ); + + const getImgWrapStyleRef = computed(() => { + const { imgWrapStyle, imgWidth } = props; + return { + width: `${imgWidth}px`, + height: `${imgWidth}px`, + ...imgWrapStyle, + }; + }); + + const getFactorRef = computed(() => { + const { minDegree, maxDegree } = props; + if (minDegree === maxDegree) { + return Math.floor(1 + Math.random() * 1) / 10 + 1; + } + return 1; + }); + function handleStart() { + state.startTime = new Date().getTime(); + } + + function handleDragBarMove(data: MoveData) { + state.draged = true; + const { imgWidth, height, maxDegree } = props; + const { moveX } = data; + const currentRotate = Math.ceil( + (moveX / (imgWidth! - parseInt(height as string))) * maxDegree! * unref(getFactorRef) + ); + state.currentRotate = currentRotate; + state.imgStyle = hackCss('transform', `rotateZ(${state.randomRotate - currentRotate}deg)`); + } + + function handleImgOnLoad() { + const { minDegree, maxDegree } = props; + const ranRotate = Math.floor(minDegree! + Math.random() * (maxDegree! - minDegree!)); // 生成随机角度 + state.randomRotate = ranRotate; + state.imgStyle = hackCss('transform', `rotateZ(${ranRotate}deg)`); + } + + function handleDragEnd() { + const { randomRotate, currentRotate } = state; + const { diffDegree } = props; + + if (Math.abs(randomRotate - currentRotate) >= (diffDegree || 20)) { + state.imgStyle = hackCss('transform', `rotateZ(${randomRotate}deg)`); + state.toOrigin = true; + useTimeout(() => { + state.toOrigin = false; + state.showTip = true; + // 时间与动画时间保持一致 + }, 300); + } else { + checkPass(); + } + state.showTip = true; + } + function checkPass() { + state.isPassing = true; + state.endTime = new Date().getTime(); + } + + function resume() { + state.showTip = false; + const basicEl = unref(basicRef); + if (!basicEl) { + return; + } + state.isPassing = false; + + basicEl.$.resume(); + handleImgOnLoad(); + } + + const instance = getCurrentInstance() as any; + if (instance) { + instance.resume = resume; + } + // handleImgOnLoad(); + return () => { + const { src } = props; + const { toOrigin, isPassing, startTime, endTime } = state; + const imgCls: string[] = []; + if (toOrigin) { + imgCls.push('to-origin'); + } + const time = (endTime - startTime) / 1000; + + return ( +
    +
    + { + resume(); + }} + /> + {state.showTip && ( + + {state.isPassing ? `校验成功,耗时${time.toFixed(1)}秒!` : '验证失败!'} + + )} + {!state.showTip && !state.draged && ( + 点击图片可刷新 + )} +
    + +
    + ); + }; + }, +}); diff --git a/src/components/Verify/src/VerifyModal.vue b/src/components/Verify/src/VerifyModal.vue new file mode 100644 index 000000000..143362dc5 --- /dev/null +++ b/src/components/Verify/src/VerifyModal.vue @@ -0,0 +1,44 @@ + diff --git a/src/components/Verify/src/props.ts b/src/components/Verify/src/props.ts new file mode 100644 index 000000000..ff7477143 --- /dev/null +++ b/src/components/Verify/src/props.ts @@ -0,0 +1,85 @@ +import type { PropType } from 'vue'; + +export const basicProps = { + value: { + type: Boolean as PropType, + default: false, + }, + + isSlot: { + type: Boolean as PropType, + default: false, + }, + + text: { + type: [String] as PropType, + default: '请按住滑块拖动', + }, + successText: { + type: [String] as PropType, + default: '验证通过', + }, + height: { + type: [Number, String] as PropType, + default: 40, + }, + + width: { + type: [Number, String] as PropType, + default: 220, + }, + + circle: { + type: Boolean as PropType, + default: false, + }, + + wrapStyle: { + type: Object as PropType, + default: {}, + }, + contentStyle: { + type: Object as PropType, + default: {}, + }, + barStyle: { + type: Object as PropType, + default: {}, + }, + actionStyle: { + type: Object as PropType, + default: {}, + }, +}; + +export const rotateProps = { + ...basicProps, + src: { + type: String as PropType, + }, + + imgWidth: { + type: Number as PropType, + default: 260, + }, + + imgWrapStyle: { + type: Object as PropType, + default: {}, + }, + + minDegree: { + type: Number as PropType, + default: 90, + }, + + maxDegree: { + type: Number as PropType, + default: 270, + }, + + diffDegree: { + type: Number as PropType, + default: 20, + }, +}; diff --git a/src/components/Verify/src/types.ts b/src/components/Verify/src/types.ts new file mode 100644 index 000000000..48f7d4c76 --- /dev/null +++ b/src/components/Verify/src/types.ts @@ -0,0 +1,14 @@ +export interface DragVerifyActionType { + resume: () => void; +} + +export interface PassingData { + isPassing: boolean; + time: number; +} + +export interface MoveData { + event: MouseEvent | TouchEvent; + moveDistance: number; + moveX: number; +} diff --git a/src/components/VirtualScroll/index.ts b/src/components/VirtualScroll/index.ts new file mode 100644 index 000000000..e1c037c21 --- /dev/null +++ b/src/components/VirtualScroll/index.ts @@ -0,0 +1 @@ +export { default as VirtualScroll } from './src/index'; diff --git a/src/components/VirtualScroll/src/index.less b/src/components/VirtualScroll/src/index.less new file mode 100644 index 000000000..2483282a8 --- /dev/null +++ b/src/components/VirtualScroll/src/index.less @@ -0,0 +1,18 @@ +.virtual-scroll { + position: relative; + display: block; + width: 100%; + max-width: 100%; + overflow: auto; + flex: 1 1 auto; + + &__container { + display: block; + } + + &__item { + position: absolute; + right: 0; + left: 0; + } +} diff --git a/src/components/VirtualScroll/src/index.tsx b/src/components/VirtualScroll/src/index.tsx new file mode 100644 index 000000000..b1073695e --- /dev/null +++ b/src/components/VirtualScroll/src/index.tsx @@ -0,0 +1,124 @@ +import { defineComponent, computed, ref, unref, reactive, onMounted, watch, nextTick } from 'vue'; +import { useEvent } from '/@/hooks/event/useEvent'; + +import { convertToUnit } from '/@/components/util'; +import { props as basicProps } from './props'; +import { getSlot } from '/@/utils/helper/tsxHelper'; +import './index.less'; + +const prefixCls = 'virtual-scroll'; +export default defineComponent({ + name: 'VirtualScroll', + props: basicProps, + setup(props, { slots }) { + const wrapElRef = ref(null); + const state = reactive({ + first: 0, + last: 0, + scrollTop: 0, + }); + + const getBenchRef = computed(() => { + return parseInt(props.bench as string, 10); + }); + + const getItemHeightRef = computed(() => { + return parseInt(props.itemHeight as string, 10); + }); + + const getFirstToRenderRef = computed(() => { + return Math.max(0, state.first - unref(getBenchRef)); + }); + + const getLastToRenderRef = computed(() => { + return Math.min((props.items || []).length, state.last + unref(getBenchRef)); + }); + + const getContainerStyleRef = computed(() => { + return { + height: convertToUnit((props.items || []).length * unref(getItemHeightRef)), + }; + }); + + const getWrapStyleRef = computed((): object => { + const styles: Record = {}; + const height = convertToUnit(props.height); + const minHeight = convertToUnit(props.minHeight); + const minWidth = convertToUnit(props.minWidth); + const maxHeight = convertToUnit(props.maxHeight); + const maxWidth = convertToUnit(props.maxWidth); + const width = convertToUnit(props.width); + + if (height) styles.height = height; + if (minHeight) styles.minHeight = minHeight; + if (minWidth) styles.minWidth = minWidth; + if (maxHeight) styles.maxHeight = maxHeight; + if (maxWidth) styles.maxWidth = maxWidth; + if (width) styles.width = width; + return styles; + }); + + watch([() => props.itemHeight, () => props.height], () => { + onScroll(); + }); + + function getLast(first: number): number { + const wrapEl = unref(wrapElRef); + if (!wrapEl) { + return 0; + } + const height = parseInt(props.height || 0, 10) || wrapEl.clientHeight; + + return first + Math.ceil(height / unref(getItemHeightRef)); + } + + function getFirst(): number { + return Math.floor(state.scrollTop / unref(getItemHeightRef)); + } + function onScroll() { + const wrapEl = unref(wrapElRef); + if (!wrapEl) { + return; + } + state.scrollTop = wrapEl.scrollTop; + state.first = getFirst(); + state.last = getLast(state.first); + } + function renderChildren() { + const { items = [] } = props; + return items.slice(unref(getFirstToRenderRef), unref(getLastToRenderRef)).map(genChild); + } + function genChild(item: any, index: number) { + index += unref(getFirstToRenderRef); + + const top = convertToUnit(index * unref(getItemHeightRef)); + return ( +
    + {getSlot(slots, 'default', { index, item })} +
    + ); + } + onMounted(() => { + state.last = getLast(0); + nextTick(() => { + const wrapEl = unref(wrapElRef); + if (!wrapEl) { + return; + } + useEvent({ + el: wrapEl, + name: 'scroll', + listener: onScroll, + wait: 0, + }); + }); + }); + return () => ( +
    +
    + {renderChildren()} +
    +
    + ); + }, +}); diff --git a/src/components/VirtualScroll/src/props.ts b/src/components/VirtualScroll/src/props.ts new file mode 100644 index 000000000..4e33bd414 --- /dev/null +++ b/src/components/VirtualScroll/src/props.ts @@ -0,0 +1,27 @@ +// Helpers + +import type { PropType } from 'vue'; +// Types + +export type NumberOrNumberString = PropType; + +export const props = { + height: [Number, String] as NumberOrNumberString, + maxHeight: [Number, String] as NumberOrNumberString, + maxWidth: [Number, String] as NumberOrNumberString, + minHeight: [Number, String] as NumberOrNumberString, + minWidth: [Number, String] as NumberOrNumberString, + width: [Number, String] as NumberOrNumberString, + bench: { + type: [Number, String] as NumberOrNumberString, + default: 0, + }, + itemHeight: { + type: [Number, String] as NumberOrNumberString, + required: true, + }, + items: { + type: Array as PropType, + default: () => [], + }, +}; diff --git a/src/components/registerGlobComp.ts b/src/components/registerGlobComp.ts new file mode 100644 index 000000000..202c2e7e6 --- /dev/null +++ b/src/components/registerGlobComp.ts @@ -0,0 +1,11 @@ +import Icon from './Icon/index'; +import { BasicHelp, BasicTitle } from './Basic'; +import Button from './Button/index.vue'; +import { App } from 'vue'; + +const compList = [Icon, BasicHelp, BasicTitle, Button]; +export function registerGlobComp(app: App) { + compList.forEach((comp: any) => { + app.component(comp.name, comp); + }); +} diff --git a/src/components/util.ts b/src/components/util.ts new file mode 100644 index 000000000..22302c89d --- /dev/null +++ b/src/components/util.ts @@ -0,0 +1,176 @@ +import type { VNodeChild } from 'vue'; + +export function convertToUnit( + str: string | number | null | undefined, + unit = 'px' +): string | undefined { + if (str == null || str === '') { + return undefined; + } else if (isNaN(+str!)) { + return String(str); + } else { + return `${Number(str)}${unit}`; + } +} + +/** + * Camelize a hyphen-delimited string. + */ +const camelizeRE = /-(\w)/g; +export const camelize = (str: string): string => { + return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : '')); +}; + +export function wrapInArray(v: T | T[] | null | undefined): T[] { + return v != null ? (Array.isArray(v) ? v : [v]) : []; +} + +const pattern = { + styleList: /;(?![^(]*\))/g, + styleProp: /:(.*)/, +} as const; + +function parseStyle(style: string) { + const styleMap: Dictionary = {}; + + for (const s of style.split(pattern.styleList)) { + let [key, val] = s.split(pattern.styleProp); + key = key.trim(); + if (!key) { + continue; + } + // May be undefined if the `key: value` pair is incomplete. + if (typeof val === 'string') { + val = val.trim(); + } + styleMap[camelize(key)] = val; + } + + return styleMap; +} + +/** + * Intelligently merges data for createElement. + * Merges arguments left to right, preferring the right argument. + * Returns new VNodeData object. + */ +export function mergeData(...vNodeData: VNodeChild[]): VNodeChild; +export function mergeData(...args: any[]): VNodeChild { + const mergeTarget: any = {}; + let i: number = args.length; + let prop: string; + + // Allow for variadic argument length. + while (i--) { + // Iterate through the data properties and execute merge strategies + // Object.keys eliminates need for hasOwnProperty call + for (prop of Object.keys(args[i])) { + switch (prop) { + // Array merge strategy (array concatenation) + case 'class': + case 'directives': + if (args[i][prop]) { + mergeTarget[prop] = mergeClasses(mergeTarget[prop], args[i][prop]); + } + break; + case 'style': + if (args[i][prop]) { + mergeTarget[prop] = mergeStyles(mergeTarget[prop], args[i][prop]); + } + break; + // Space delimited string concatenation strategy + case 'staticClass': + if (!args[i][prop]) { + break; + } + if (mergeTarget[prop] === undefined) { + mergeTarget[prop] = ''; + } + if (mergeTarget[prop]) { + // Not an empty string, so concatenate + mergeTarget[prop] += ' '; + } + mergeTarget[prop] += args[i][prop].trim(); + break; + // Object, the properties of which to merge via array merge strategy (array concatenation). + // Callback merge strategy merges callbacks to the beginning of the array, + // so that the last defined callback will be invoked first. + // This is done since to mimic how Object.assign merging + // uses the last given value to assign. + case 'on': + case 'nativeOn': + if (args[i][prop]) { + mergeTarget[prop] = mergeListeners(mergeTarget[prop], args[i][prop]); + } + break; + // Object merge strategy + case 'attrs': + case 'props': + case 'domProps': + case 'scopedSlots': + case 'staticStyle': + case 'hook': + case 'transition': + if (!args[i][prop]) { + break; + } + if (!mergeTarget[prop]) { + mergeTarget[prop] = {}; + } + mergeTarget[prop] = { ...args[i][prop], ...mergeTarget[prop] }; + break; + // Reassignment strategy (no merge) + default: + // slot, key, ref, tag, show, keepAlive + if (!mergeTarget[prop]) { + mergeTarget[prop] = args[i][prop]; + } + } + } + } + + return mergeTarget; +} + +export function mergeStyles( + target: undefined | string | object[] | object, + source: undefined | string | object[] | object +) { + if (!target) return source; + if (!source) return target; + + target = wrapInArray(typeof target === 'string' ? parseStyle(target) : target); + + return (target as object[]).concat(typeof source === 'string' ? parseStyle(source) : source); +} + +export function mergeClasses(target: any, source: any) { + if (!source) return target; + if (!target) return source; + + return target ? wrapInArray(target).concat(source) : source; +} + +export function mergeListeners( + target: { [key: string]: Function | Function[] } | undefined, + source: { [key: string]: Function | Function[] } | undefined +) { + if (!target) return source; + if (!source) return target; + + let event: string; + + for (event of Object.keys(source)) { + // Concat function to array of functions if callback present. + if (target[event]) { + // Insert current iteration data in beginning of merged array. + target[event] = wrapInArray(target[event]); + (target[event] as Function[]).push(...wrapInArray(source[event])); + } else { + // Straight assign. + target[event] = source[event]; + } + } + + return target; +} diff --git a/src/design/ant/btn.less b/src/design/ant/btn.less new file mode 100644 index 000000000..b12746e27 --- /dev/null +++ b/src/design/ant/btn.less @@ -0,0 +1,195 @@ +// button重置 +.ant-btn { + &.ant-btn-success:not(.ant-btn-link), + &.ant-btn-error:not(.ant-btn-link), + &.ant-btn-warning:not(.ant-btn-link), + &.ant-btn-primary:not(.ant-btn-link) { + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.08) !important; + } + + &-primary { + color: @white; + background-color: @button-primary-color; + border-width: 0; + + &:hover, + &:focus { + color: @white; + background-color: @button-primary-hover-color; + } + + &[disabled], + &[disabled]:hover { + color: @white; + background-color: fade(@button-primary-color, 40%); + } + } + + &-default { + color: @button-cancel-color; + background-color: @button-cancel-bg-color; + border-color: @button-cancel-border-color; + + &:hover, + &:focus { + color: @button-cancel-hover-color; + background-color: @button-cancel-hover-bg-color; + border-color: @button-cancel-hover-border-color; + } + + &[disabled], + &[disabled]:hover { + color: fade(@button-cancel-color, 40%); + background: fade(@button-cancel-bg-color, 40%); + border-color: fade(@button-cancel-border-color, 40%); + } + } + + &.ant-btn-link.is-disabled { + color: rgba(0, 0, 0, 0.25) !important; + text-shadow: none; + cursor: not-allowed; + background-color: transparent; + border-color: transparent; + box-shadow: none; + } + + // color: @white; + + &-success.ant-btn-link:not([disabled='disabled']) { + color: @button-success-color; + + &:hover, + &:focus { + color: @button-success-hover-color; + } + } + + &-success.ant-btn-link.ant-btn-loading, + &-warning.ant-btn-link.ant-btn-loading, + &-error.ant-btn-link.ant-btn-loading, + &-background-ghost.ant-btn-link.ant-btn-loading, + &.ant-btn-link.ant-btn-loading { + &::before { + background: transparent; + } + } + + &-success:not(.ant-btn-link) { + color: @white; + background-color: @button-success-color; + border-color: @button-success-color; + border-width: 0; + + &:hover, + &:focus { + color: @white; + background-color: @button-success-hover-color; + border-color: @button-success-hover-color; + } + + &[disabled], + &[disabled]:hover { + color: @white; + background-color: fade(@button-success-color, 40%); + // background-color: @button-success-disabled-color; + border-color: fade(@button-success-color, 40%); + } + } + + &-warning.ant-btn-link:not([disabled='disabled']) { + color: @button-warn-color; + + &:hover, + &:focus { + color: @button-warn-hover-color; + } + } + + &-warning:not(.ant-btn-link) { + color: @white; + background-color: @button-warn-color; + border-color: @button-warn-color; + border-width: 0; + + &:hover, + &:focus { + color: @white; + background-color: @button-warn-hover-color; + border-color: @button-warn-hover-color; + } + + &[disabled], + &[disabled]:hover { + color: @white; + background-color: fade(@button-warn-color, 40%); + border-color: fade(@button-warn-color, 40%); + + // background-color: @button-warn-disabled-color; + // border-color: @button-warn-disabled-color ; + } + } + + &-error.ant-btn-link:not([disabled='disabled']) { + color: @button-error-color; + + &:hover, + &:focus { + color: @button-error-hover-color; + } + } + + &-error:not(.ant-btn-link) { + color: @white; + background-color: @button-error-color; + border-color: @button-error-color; + border-width: 0; + + &:hover, + &:focus { + color: @white; + background-color: @button-error-hover-color; + border-color: @button-error-hover-color; + } + + &[disabled], + &[disabled]:hover { + color: @white; + background-color: fade(@button-error-color, 40%); + border-color: fade(@button-error-color, 40%); + + // background-color: @button-error-disabled-color; + // border-color: @button-error-disabled-color ; + } + } + + &-background-ghost.ant-btn-link, + &.ant-btn-dashed:not([disabled='disabled']) { + color: @text-color-call-out; + + &:hover { + color: @button-primary-color; + } + } + + &-ghost { + color: @button-ghost-color; + background-color: @white; + border-color: @button-ghost-color; + border-width: 1px; + + &:hover, + &:focus { + color: @button-ghost-hover-color; + background-color: @button-ghost-hover-bg-color; + border-color: @button-ghost-hover-color; + } + + &[disabled], + &[disabled]:hover { + color: @button-ghost-color; + background: fade(@white, 40%); + border-color: fade(@button-ghost-color, 40%); + } + } +} diff --git a/src/design/ant/index.less b/src/design/ant/index.less new file mode 100644 index 000000000..dddfaf084 --- /dev/null +++ b/src/design/ant/index.less @@ -0,0 +1,133 @@ +@import './pagination.less'; +@import './input.less'; +@import './btn.less'; + +// ================================= +// ==============descriptions======= +// ================================= +.ant-descriptions-bordered .ant-descriptions-item-label { + background-color: @background-color-light; +} + +.ant-descriptions .ant-descriptions-item-content { + color: @text-color-call-out; +} +// ================================= +// ==============modal message====== +// ================================= +.modal-icon-warning { + color: @warning-color !important; +} + +.modal-icon-success { + color: @success-color !important; +} + +.modal-icon-error { + color: @error-color !important; +} + +.ant-modal-mask { + background-color: rgba(0, 0, 0, 0.2); +} +// ================================= +// ==============menu=============== +// ================================= +.ant-menu-item { + &-selected { + a { + color: @primary-color; + + &:hover { + color: @primary-color; + } + } + } +} +// ================================= +// ==============dropdown=========== +// ================================= +.ant-dropdown { + .ant-divider { + margin: 4px 0; + } + + &-menu-item { + line-height: 30px; + color: @text-color-call-out; + + &:hover { + color: inherit; + background-color: @border-color-shallow-light; + } + } +} +// ================================= +// ==============back-top=========== +// ================================= +.ant-back-top { + right: 50px; + bottom: 60px; +} +// ================================= +// ==============calendar=========== +// ================================= +.ant-calendar-picker { + width: 100%; +} +// ================================= +// ==============tooltip============ +// ================================= + +.ant-tooltip { + &-inner { + padding: 6px 16px; + line-height: 20px; + color: @white; + background: @text-color-base; + border-radius: 4px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + } +} +// ================================= +// ==============form=============== +// ================================= +.ant-form-item.deltag .ant-form-item-required::before { + content: ''; +} + +.ant-form-item { + &-label label::after { + margin: 0 6px 0 2px; + } + + &-control { + line-height: 36px; + } +} + +.ant-form-explain { + margin-bottom: 2px; + font-size: 14px; +} + +.compact-form-row { + .ant-form-item { + margin-bottom: 8px; + } +} + +// ================================= +// ==============empty============== +// ================================= +.ant-empty-image { + max-height: 144px; + min-height: 60px; +} + +.ant-empty-description { + margin-top: 16px; + font-size: 14px; + line-height: 24px; + color: @text-color-call-out; +} diff --git a/src/design/ant/input.less b/src/design/ant/input.less new file mode 100644 index 000000000..56769428e --- /dev/null +++ b/src/design/ant/input.less @@ -0,0 +1,24 @@ +@import '../mixins.less'; +@import '../color.less'; + +// input +.ant-input { + &-number { + min-width: 110px; + border-color: @border-color-shallow-dark; + } +} + +.ant-input-affix-wrapper:hover .ant-input:not(.ant-input-disabled) { + border-color: @info-color; +} + +.ant-input-disabled, +.ant-select-disabled .ant-select-selection, +.ant-cascader-picker-label { + color: @text-color-base !important; +} + +.ant-input-disabled { + background-color: @background-color-light; +} diff --git a/src/design/ant/pagination.less b/src/design/ant/pagination.less new file mode 100644 index 000000000..5ff4dfbe2 --- /dev/null +++ b/src/design/ant/pagination.less @@ -0,0 +1,98 @@ +body { + .ant-pagination { + &.mini { + height: 20px; + font-size: 13px; + + .ant-pagination-prev, + .ant-pagination-next { + width: 20px; + height: 20px; + min-width: 20px; + line-height: 17px; + color: @border-color-shallow-dark; + border: 1px solid; + } + + .ant-pagination-prev:hover, + .ant-pagination-next:hover, + .ant-pagination-item:focus, + .ant-pagination-item:hover { + color: @primary-color; + border: 1px solid @primary-color; + } + + .ant-pagination-item { + height: 20px; + min-width: 20px; + margin: 0 3px; + line-height: 20px; + + &:last-child { + margin-right: 0 !important; + } + } + + .ant-pagination-item-active { + background: @primary-color; + + a { + color: @white; + } + } + + .ant-pagination-options { + margin-left: 20px; + } + + .ant-select-sm .ant-select-selection--single { + height: 20px; + } + + .ant-pagination-options, + .ant-pagination-total-text, + .ant-pagination-options-quick-jumper { + height: 20px; + line-height: 20px; + } + + .ant-select-selection__rendered { + height: 18px; + line-height: 18px; + } + + .ant-pagination-total-text, + .ant-select-selection__rendered, + .ant-select-dropdown-menu-item, + .ant-pagination-options-quick-jumper { + font-size: 13px; + } + + .ant-pagination-options-quick-jumper input { + width: 40px; + height: 20px; + margin: 0 6px; + line-height: 20px; + text-align: center; + } + + .ant-pagination-jump-prev, + .ant-pagination-jump-next { + height: 20px; + line-height: 20px; + } + + .ant-pagination-options-size-changer.ant-select { + margin-right: 20px; + } + + .ant-select-arrow { + color: @border-color-shallow-dark; + } + } + + &-disabled { + display: none; + } + } +} diff --git a/src/design/ant/selection.less b/src/design/ant/selection.less new file mode 100644 index 000000000..8aa03acf2 --- /dev/null +++ b/src/design/ant/selection.less @@ -0,0 +1,81 @@ +.ant-radio { + &-inner { + border-color: @text-color-base; + + &::after { + top: 1px; + left: 1px; + width: 12px; + height: 12px; + } + } +} + +.ant-radio-disabled .ant-radio-inner { + border-color: @text-color-help-light !important; +} + +.ant-checkbox { + &-inner { + border-color: @text-color-base; + } +} + +.ant-checkbox-disabled .ant-checkbox-inner { + border-color: @text-color-help-light !important; +} + +// select +.ant-select { + &-selection { + border-color: @border-color-shallow-dark; + } + + &-selection__placeholder, + &-search__field__placeholder { + color: @text-color-help-dark; + } +} + +.ant-select-disabled .ant-select-arrow { + visibility: hidden; +} + +.ant-select-dropdown { + min-width: 84px !important; + + &.ant-select-dropdown--multiple { + .ant-select-dropdown-menu-item-selected .ant-select-selected-icon, + .ant-select-dropdown-menu-item-selected:hover .ant-select-selected-icon { + color: @white; + } + } + + &-menu-item { + height: 34px; + min-width: 84px; + line-height: 22px; + color: @text-color-call-out; + + &:hover { + // color: @--norm-select-item-hover-color; + background: @tree-hover-background-color; + } + + &-selected, + &-selected:hover { + color: @primary-color; + background: @tree-hover-background-color; + } + + &-disabled, + &-disabled:hover { + color: @disabled-color; + background-color: @white; + } + } +} + +.ant-select-disabled .ant-select-selection { + background: @tree-hover-font-color; +} diff --git a/src/design/color.less b/src/design/color.less new file mode 100644 index 000000000..98d56e180 --- /dev/null +++ b/src/design/color.less @@ -0,0 +1,141 @@ +@white: #fff; +@info-color: @primary-color; + +@basic-mask-color: fade(@white, 30%); +// :export { +// name: "less"; +// mainColor: @mainColor; +// fontSize: @fontSize; +// } +@iconify-bg-color: #5551; + +// ================================= +// ==============边框颜色============ +// ================================= + +// 暗色-深 +@border-color-dark: #b6b7b9; + +// 暗色-浅 +@border-color-shallow-dark: #cececd; + +// 亮色-深 +@border-color-light: #ebeef5; + +// 亮色-浅 +@border-color-shallow-light: #f2f6fc; + +// ================================= +// ==============message============== +// ================================= +// 成功背景颜色 +@success-background-color: #f1f9ec; +// 一般背景颜色 +@info-background-color: #e8eff8; +// 警告背景颜色 +@warning-background-color: #fdf6ed; +// 危险背景颜色 +@danger-background-color: #fef0f0; +// tag标签:失效 +@invalid-color: #909399; + +// ================================= +// ==============bg color============ +// ================================= + +// dark +@background-color-dark: #f0f2f5; +// light +@background-color-light: #f5f7fa; +// layout content background +@layout-content-bg-color: #f1f1f6; + +// ================================= +// ==============Header============= +// ================================= + +@header-dark-bg-color: #394664; +@header-dark-bg-hover-color: #273352; +@header-light-bg-hover-color: #f6f6f6; +@header-light-desc-color: #7c8087; +@header-light-bottom-border-color: #eee; +// ================================= +// ==============Menu============ +// ================================= + +// let -menu +@first-menu-item-dark-bg-color: #273352; + +// 2级菜单黑暗背景色 +@sub-menu-item-dark-bg-color: #4f6088; +// 3级菜单黑暗背景色 +@children-menu-item-dark-bg-color: #314268; + +// top-menu +@top-menu-active-bg-color: #273352; + +// trigger +@trigger-dark-hover-bg-color: rgba(255, 255, 255, 0.2); +@trigger-dark-bg-color: rgba(255, 255, 255, 0.1); +@trigger-light-bg-color: @white; +@trigger-light-hover-bg-color: rgba(255, 255, 255, 0.7); + +// ================================= +// ==============tree============ +// ================================= +// tree item hover background +@tree-hover-background-color: #f5f7fa; +// tree item hover font color +@tree-hover-font-color: #f5f7fa; +// ================================= +// ==============link============ +// ================================= +@link-hover-color: @primary-color; +@link-active-color: darken(@primary-color, 10%); + +// ================================= +// ==============文本色-============= +// ================================= + +// 主文本色 +@text-color-base: #2c3a61; +// 标注色 +@text-color-call-out: #606266; + +// 辅助说明信息色-深色 +@text-color-help-dark: #909399; + +// 辅助说明信息色-浅色 +@text-color-help-light: #c0c4cc; + +// ================================= +// ==============breadcrumb========= +// ================================= +@breadcrumb-item-normal-color: #6e90a7; +// ================================= +// ==============button============= +// ================================= + +@button-primary-color: @primary-color; +@button-primary-hover-color: darken(@primary-color, 5%); + +@button-ghost-color: @primary-color; +@button-ghost-hover-color: lighten(@primary-color, 10%); +@button-ghost-hover-bg-color: #e1ebf6; + +@button-success-color: @success-color; +@button-success-hover-color: darken(@success-color, 10%); + +@button-warn-color: @warning-color; +@button-warn-hover-color: darken(@warning-color, 10%); + +@button-error-color: @error-color; +@button-error-hover-color: darken(@error-color, 10%); + +@button-cancel-color: @text-color-call-out; +@button-cancel-bg-color: @white; +@button-cancel-border-color: @border-color-shallow-dark; +// 鼠标悬停 +@button-cancel-hover-color: @primary-color; +@button-cancel-hover-bg-color: @white; +@button-cancel-hover-border-color: @primary-color; diff --git a/src/design/index.less b/src/design/index.less new file mode 100644 index 000000000..1bf272e64 --- /dev/null +++ b/src/design/index.less @@ -0,0 +1,157 @@ +@import './transition/index.less'; +@import 'var/index.less'; +@import 'public.less'; +@import 'mixins.less'; +@import 'ant/index.less'; + +*, +*::before, +*::after { + padding: 0; + margin: 0; + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + + &.color-weak { + filter: invert(80%); + } + + &.gray-mode { + filter: grayscale(100%); + filter: progid:dximagetransform.microsoft.basicimage(grayscale=1); + } +} + +// remove the clear button of a text input control in IE10+ +input::-ms-clear, +input::-ms-reveal { + display: none; +} + +body { + font-family: 'Microsoft YaHei,微软雅黑,Arial,sans-serif,Helvetica Neue,Helvetica,Pingfang SC,Hiragino Sans GB'; + font-style: normal; + font-weight: normal; + line-height: 1.428571429; // 20/14 + letter-spacing: normal; + word-spacing: normal; + text-align: left; // Fallback for where `start` is not supported + text-align: start; + text-decoration: none; + text-size-adjust: 100%; + text-shadow: none; + text-transform: none; + word-break: normal; + word-wrap: normal; + white-space: normal; + line-break: auto; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 0; + margin-bottom: 0.5em; + font-weight: 500; + color: @heading-color; +} + +ul, +ol { + list-style: none; +} + +li { + list-style-type: none; +} + +img { + vertical-align: top; + border: 0; +} + +table { + border-collapse: collapse; + border-spacing: 0; +} + +a:focus, +a:active { + outline: none; +} + +i, +em { + font-style: normal; +} + +button, +div:focus { + outline: none !important; +} + +a { + color: @link-color; + text-decoration: none; + cursor: pointer; + background-color: transparent; // remove the gray background on active links in IE 10. + outline: none; + transition: color 0.3s; + -webkit-text-decoration-skip: objects; // remove gaps in links underline in iOS 8+ and Safari 8+. + + &:hover { + color: @link-hover-color; + } + + &:active { + color: @link-active-color; + } + + &:active, + &:hover { + text-decoration: none; + outline: 0; + } + + &[disabled] { + color: @disabled-color; + pointer-events: none; + cursor: not-allowed; + } +} + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + vertical-align: baseline !important; +} + +#app, +#app > div, +.ant-layout { + width: 100%; + height: 100%; +} + +.ant-layout { + background: #f1f1f6; + + &-content { + position: relative; + overflow: hidden; + } +} diff --git a/src/design/main.postcss b/src/design/main.postcss new file mode 100644 index 000000000..a31e44411 --- /dev/null +++ b/src/design/main.postcss @@ -0,0 +1,3 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; diff --git a/src/design/mixins.less b/src/design/mixins.less new file mode 100644 index 000000000..2346a0405 --- /dev/null +++ b/src/design/mixins.less @@ -0,0 +1,58 @@ +.setPlaceholder(@prefix,@color:@text-color-help-dark) { + // input + @{prefix}::-webkit-input-placeholder { + color: @color; + } + @{prefix}::-moz-placeholder { + /* Mozilla Firefox 19+ */ + color: @color; + } + @{prefix}:-moz-placeholder { + /* Mozilla Firefox 4 to 18 */ + color: @color; + } + @{prefix}:-ms-input-placeholder { + /* Internet Explorer 10-11 */ + color: @color; + } +} +// 文本截断 +.text-truncate() { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 强制不换行 */ +.word-nowrap() { + word-wrap: normal; + white-space: nowrap; +} + +/* 强制换行 */ +.break-all() { + word-break: break-all; + word-wrap: break-word; + white-space: normal; +} + +// 禁止选中 +.unselect() { + cursor: pointer; + user-select: none; +} + +.ellipsis { + overflow: hidden; + text-overflow: ellipsis; + word-wrap: normal; + white-space: nowrap; +} + +/* 适用于webkit内核和移动端 */ +.ellipsis-multiple(@num: 1) { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: @num; +} diff --git a/src/design/public.less b/src/design/public.less new file mode 100644 index 000000000..cb8ab901c --- /dev/null +++ b/src/design/public.less @@ -0,0 +1,23 @@ +/* 滚动槽 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +// TODO 滚动条样式-待修改 +::-webkit-scrollbar-track { + // background: rgba(0, 0, 0, 0.06); + // border-radius: 2px; + // box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.05); +} + +/* 滚动条滑块 */ +::-webkit-scrollbar-thumb { + background: @disabled-color; + border-radius: 4px; + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1); +} + +::-webkit-scrollbar-thumb:hover { + background: @border-color-dark; +} diff --git a/src/design/reset.less b/src/design/reset.less new file mode 100644 index 000000000..9f54f2008 --- /dev/null +++ b/src/design/reset.less @@ -0,0 +1,121 @@ +@import 'var/link'; +@import './mixins/reset-text.less'; +@import 'color/index'; +.reset() { + html, + body { + width: 100%; + height: 100%; + // overflow: hidden; + + &.color-weak { + filter: invert(80%); + } + + &.gray-mode { + filter: grayscale(100%); + filter: progid:dximagetransform.microsoft.basicimage(grayscale=1); + } + } + + // remove the clear button of a text input control in IE10+ + input::-ms-clear, + input::-ms-reveal { + display: none; + } + + body { + // 重置文本样式 + .reset-text(); + + // font-size: @--base-font-size; + // color: @--norm-text-color; + // background-color: @--norm-background-dark-color !important; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 0; + margin-bottom: 0.5em; + font-weight: 500; + color: @heading-color; + } + + ul, + ol { + list-style: none; + } + + li { + list-style-type: none; + } + + img { + vertical-align: top; + border: 0; + } + + table { + border-collapse: collapse; + border-spacing: 0; + } + + a:focus, + a:active { + outline: none; + } + + i, + em { + font-style: normal; + } + + div:focus { + outline: none; + } + + a { + color: @link-color; + text-decoration: @link-decoration; + cursor: pointer; + background-color: transparent; // remove the gray background on active links in IE 10. + outline: none; + transition: color 0.3s; + -webkit-text-decoration-skip: objects; // remove gaps in links underline in iOS 8+ and Safari 8+. + + &:hover { + color: @link-hover-color; + } + + &:active { + color: @link-active-color; + } + + &:active, + &:hover { + text-decoration: @link-hover-decoration; + outline: 0; + } + + &[disabled] { + color: @disabled-color; + pointer-events: none; + cursor: not-allowed; + } + } +} + +.reset-layout() { + .ant-layout { + background: #f1f1f6 !important; + + &-content { + position: relative; + overflow: hidden; + } + } +} diff --git a/src/design/transition/base.less b/src/design/transition/base.less new file mode 100644 index 000000000..c5edb8c60 --- /dev/null +++ b/src/design/transition/base.less @@ -0,0 +1,18 @@ +.transition-default() { + &-enter-active, + &-leave-active { + transition: 0.3s cubic-bezier(0.25, 0.8, 0.5, 1) !important; + } + + &-move { + transition: transform 0.6s; + } +} + +.expand-transition { + .transition-default(); +} + +.expand-x-transition { + .transition-default(); +} diff --git a/src/design/transition/breadcrumb.less b/src/design/transition/breadcrumb.less new file mode 100644 index 000000000..677f73057 --- /dev/null +++ b/src/design/transition/breadcrumb.less @@ -0,0 +1,18 @@ +.breadcrumb-enter-active, +.breadcrumb-leave-active { + transition: all 0.38s; +} + +.breadcrumb-enter-from, +.breadcrumb-leave-active { + opacity: 0; + transform: translateX(16px); +} + +.breadcrumb-move { + transition: all 0.38s; +} + +.breadcrumb-leave-active { + position: absolute; +} diff --git a/src/design/transition/fade.less b/src/design/transition/fade.less new file mode 100644 index 000000000..27433c27b --- /dev/null +++ b/src/design/transition/fade.less @@ -0,0 +1,148 @@ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.28s ease-in-out; +} + +.fade-enter-from, +.fade-enter, +.fade-leave-to { + opacity: 0; +} + +/* fade-transform */ +// .fade-transform-leave-active, +// .fade-transform-enter-active { +// transition: all 0.5s; +// } + +// .fade-transform-enter { +// opacity: 0; +// transform: translateX(-30px); +// } + +// .fade-transform-leave-to { +// opacity: 0; +// transform: translateX(30px); +// } + +// side-fade +.slide-fade-enter-active, +.slide-fade-leave-active { + transition: opacity 0.35s, transform 0.4s; +} + +.slide-enter-from, +.slide-fade-enter { + opacity: 0; + transform: translateX(-30%); +} + +.slide-fade-leave-to { + opacity: 0; + transform: translateX(30%); +} + +// zoom-out +.zoom-out-enter-active, +.zoom-out-leave-active { + transition: opacity 0.35s ease-in-out, transform 0.45s ease-out; +} + +.zoom-out-enter-from, +.zoom-out-enter, +.zoom-out-leave-to { + opacity: 0; + transform: scale(0); +} + +// zoom-fade +.zoom-fade-enter-active, +.zoom-fade-leave-active { + transition: transform 0.35s, opacity 0.35s ease-out; +} + +.zoom-fade-enter-from { + opacity: 0; + transform: scale(0.97); +} + +.zoom-fade-leave-to { + opacity: 0; + transform: scale(1.03); +} + +// /////////////////////////////////////////////// +// Fade Bottom +// /////////////////////////////////////////////// + +// Speed: 1x +.fade-bottom-enter-active, +.fade-bottom-leave-active { + transition: opacity 0.3s, transform 0.35s; +} + +.fade-bottom-enter-from, +.fade-bottom-enter { + opacity: 0; + transform: translateY(-8%); +} + +.fade-bottom-leave-to { + opacity: 0; + transform: translateY(8%); +} + +// Speed: 2x +.fade-bottom-2x-enter-active, +.fade-bottom-2x-leave-active { + transition: opacity 0.2s, transform 0.25s; +} + +.fade-bottom-2x-enter-from, +.fade-bottom-2x-enter { + opacity: 0; + transform: translateY(-4%); +} + +.fade-bottom-2x-leave-to { + opacity: 0; + transform: translateY(4%); +} + +// /////////////////////////////////////////////// +// Fade Top +// /////////////////////////////////////////////// + +// Speed: 1x +.fade-top-enter-active, +.fade-top-leave-active { + transition: opacity 0.3s, transform 0.35s; +} + +.fade-top-enter-from, +.fade-top-enter { + opacity: 0; + transform: translateY(8%); +} + +.fade-top-leave-to { + opacity: 0; + transform: translateY(-8%); +} + +// Speed: 2x +.fade-top-2x-enter-active, +.fade-top-2x-leave-active { + transition: opacity 0.2s, transform 0.25s; +} + +.fade-top-2x-enter-from, +.fade-top-2x-enter { + opacity: 0; + transform: translateY(4%); +} + +.fade-top-2x-leave-to { + opacity: 0; + transform: translateY(-4%); +} diff --git a/src/design/transition/index.less b/src/design/transition/index.less new file mode 100644 index 000000000..4a818876e --- /dev/null +++ b/src/design/transition/index.less @@ -0,0 +1,6 @@ +@import './base.less'; +@import './fade.less'; +@import './scale.less'; +@import './slide.less'; +@import './scroll.less'; +@import './breadcrumb.less'; diff --git a/src/design/transition/scale.less b/src/design/transition/scale.less new file mode 100644 index 000000000..e21febab8 --- /dev/null +++ b/src/design/transition/scale.less @@ -0,0 +1,22 @@ +.scale-transition { + .transition-default(); + + &-enter-from, + &-leave, + &-leave-to { + opacity: 0; + transform: scale(0); + } +} + +.scale-rotate-transition { + .transition-default(); + + &-enter, + &-enter-from, + &-leave, + &-leave-to { + opacity: 0; + transform: scale(0) rotate(-45deg); + } +} diff --git a/src/design/transition/scroll.less b/src/design/transition/scroll.less new file mode 100644 index 000000000..7a329d9d9 --- /dev/null +++ b/src/design/transition/scroll.less @@ -0,0 +1,75 @@ +.scroll-y-transition { + .transition-default(); + + &-enter, + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-from, + &-enter { + transform: translateY(-15px); + } + + &-leave-to { + transform: translateY(15px); + } +} + +.scroll-y-reverse-transition { + .transition-default(); + + &-enter, + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-from, + &-enter { + transform: translateY(15px); + } + + &-leave-to { + transform: translateY(-15px); + } +} + +.scroll-x-transition { + .transition-default(); + + &-enter, + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-from, + &-enter { + transform: translateX(-15px); + } + + &-leave-to { + transform: translateX(15px); + } +} + +.scroll-x-reverse-transition { + .transition-default(); + + &-enter, + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-from, + &-enter { + transform: translateX(15px); + } + + &-leave-to { + transform: translateX(-15px); + } +} diff --git a/src/design/transition/slide.less b/src/design/transition/slide.less new file mode 100644 index 000000000..890e03a66 --- /dev/null +++ b/src/design/transition/slide.less @@ -0,0 +1,43 @@ +.slide-y-transition { + .transition-default(); + + &-enter-from, + &-enter, + &-leave-to { + opacity: 0; + transform: translateY(-15px); + } +} + +.slide-y-reverse-transition { + .transition-default(); + + &-enter-from, + &-enter, + &-leave-to { + opacity: 0; + transform: translateY(15px); + } +} + +.slide-x-transition { + .transition-default(); + + &-enter-from, + &-enter, + &-leave-to { + opacity: 0; + transform: translateX(-15px); + } +} + +.slide-x-reverse-transition { + .transition-default(); + + &-enter-from, + &-enter, + &-leave-to { + opacity: 0; + transform: translateX(15px); + } +} diff --git a/src/design/var/breakpoint.less b/src/design/var/breakpoint.less new file mode 100644 index 000000000..7b84ffe40 --- /dev/null +++ b/src/design/var/breakpoint.less @@ -0,0 +1,34 @@ +// ================================= +// ==============屏幕断点============ +// ================================= + +// Extra small screen / phone +@screen-xs: 480px; +@screen-xs-min: @screen-xs; + +// Small screen / tablet +@screen-sm: 576px; +@screen-sm-min: @screen-sm; + +// Medium screen / desktop +@screen-md: 768px; +@screen-md-min: @screen-md; + +// Large screen / wide desktop +@screen-lg: 992px; +@screen-lg-min: @screen-lg; + +// Extra large screen / full hd +@screen-xl: 1200px; +@screen-xl-min: @screen-xl; + +// Extra extra large screen / large desktop +@screen-xxl: 1600px; +@screen-xxl-min: @screen-xxl; + +// provide a maximum +@screen-xs-max: (@screen-sm-min - 1px); +@screen-sm-max: (@screen-md-min - 1px); +@screen-md-max: (@screen-lg-min - 1px); +@screen-lg-max: (@screen-xl-min - 1px); +@screen-xl-max: (@screen-xxl-min - 1px); diff --git a/src/design/var/easing.less b/src/design/var/easing.less new file mode 100644 index 000000000..e19735f69 --- /dev/null +++ b/src/design/var/easing.less @@ -0,0 +1,18 @@ +// ================================= +// ==============动画函数-=========== +// ================================= + +@ease-base-out: cubic-bezier(0.7, 0.3, 0.1, 1); +@ease-base-in: cubic-bezier(0.9, 0, 0.3, 0.7); +@ease-out: cubic-bezier(0.215, 0.61, 0.355, 1); +@ease-in: cubic-bezier(0.55, 0.055, 0.675, 0.19); +@ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1); +@ease-out-back: cubic-bezier(0.12, 0.4, 0.29, 1.46); +@ease-in-back: cubic-bezier(0.71, -0.46, 0.88, 0.6); +@ease-in-out-back: cubic-bezier(0.71, -0.46, 0.29, 1.46); +@ease-out-circ: cubic-bezier(0.08, 0.82, 0.17, 1); +@ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.34); +@ease-in-out-circ: cubic-bezier(0.78, 0.14, 0.15, 0.86); +@ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1); +@ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06); +@ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1); diff --git a/src/design/var/index.less b/src/design/var/index.less new file mode 100644 index 000000000..b39fcc0a4 --- /dev/null +++ b/src/design/var/index.less @@ -0,0 +1,21 @@ +@import '../color.less'; +@import 'easing'; +@import 'breakpoint'; + +// tabs +@multiple-height: 36px; + +// headers +@header-height: 42px; + +// logo width +@logo-width: 32px; + +// +@sider-drag-z-index: 200; +@page-loading-z-index: 10000; + +// app menu + +// left-menu +@app-menu-item-height: 44px; diff --git a/src/enums/appEnum.ts b/src/enums/appEnum.ts new file mode 100644 index 000000000..f74656572 --- /dev/null +++ b/src/enums/appEnum.ts @@ -0,0 +1,32 @@ +export enum ContentEnum { + // auto width + FULL = 'full', + // fixed width + FIXED = 'fixed', +} + +// app current theme +export enum ThemeModeEnum { + LIGHT = 'light-mode', + DARK = 'dark-mode', + SEMI_DARK = 'semi-dark-mode', +} + +/** + * 权限模式 + */ +export enum PermissionModeEnum { + // role + ROLE = 'ROLE', + // black + BACK = 'BACK', +} + +// Route switching animation +export enum RouterTransitionEnum { + ZOOM_FADE = 'zoom-fade', + ZOOM_OUT = 'zoom-out', + SIDE_FADE = 'slide-fade', + FADE = 'fade', + FADE_BOTTOM = 'fade-bottom', +} diff --git a/src/enums/breakpointEnum.ts b/src/enums/breakpointEnum.ts new file mode 100644 index 000000000..93acc1a31 --- /dev/null +++ b/src/enums/breakpointEnum.ts @@ -0,0 +1,28 @@ +export enum sizeEnum { + XS = 'XS', + SM = 'SM', + MD = 'MD', + LG = 'LG', + XL = 'XL', + XXL = 'XXL', +} + +export enum screenEnum { + XS = 480, + SM = 576, + MD = 768, + LG = 992, + XL = 1200, + XXL = 1600, +} + +const screenMap = new Map(); + +screenMap.set(sizeEnum.XS, screenEnum.XS); +screenMap.set(sizeEnum.SM, screenEnum.SM); +screenMap.set(sizeEnum.MD, screenEnum.MD); +screenMap.set(sizeEnum.LG, screenEnum.LG); +screenMap.set(sizeEnum.XL, screenEnum.XL); +screenMap.set(sizeEnum.XXL, screenEnum.XXL); + +export { screenMap }; diff --git a/src/enums/cacheEnum.ts b/src/enums/cacheEnum.ts new file mode 100644 index 000000000..77cfdbdda --- /dev/null +++ b/src/enums/cacheEnum.ts @@ -0,0 +1,20 @@ +// token key +export const TOKEN_KEY = 'TOKEN'; + +// user info key +export const USER_INFO_KEY = 'USER__INFO__'; + +// role info key +export const ROLES_KEY = 'ROLES__KEY__'; + +// project config key +export const PROJ_CFG_KEY = 'PROJ__CFG__KEY__'; + +// lock info +export const LOCK_INFO_KEY = 'LOCK__INFO__KEY__'; + +// base global local key +export const BASE_LOCAL_CACHE_KEY = 'LOCAL__CACHE__KEY__'; + +// base global session key +export const BASE_SESSION_CACHE_KEY = 'SESSION__CACHE__KEY__'; diff --git a/src/enums/exceptionEnum.ts b/src/enums/exceptionEnum.ts new file mode 100644 index 000000000..1f476bf3b --- /dev/null +++ b/src/enums/exceptionEnum.ts @@ -0,0 +1,25 @@ +/** + * @description: Exception related enumeration + */ +export enum ExceptionEnum { + // page not found + PAGE_NOT_FOUND = 404, + + // error + ERROR = 500, + + // net work error + NET_WORK_ERROR = 10000, + + // net work timeout + NET_WORK_TIMEOUT = 10100, + + // not data + NOT_DATA = 10200, + + // The page loads too long and timeout + PAGE_TIMEOUT = 10300, + + // No data on the page. In fact, it is not an exception page + PAGE_NOT_DATA = 10400, +} diff --git a/src/enums/httpEnum.ts b/src/enums/httpEnum.ts new file mode 100644 index 000000000..4944af5c2 --- /dev/null +++ b/src/enums/httpEnum.ts @@ -0,0 +1,31 @@ +/** + * @description: Request result set + */ +export enum ResultEnum { + SUCCESS = 0, + ERROR = 1, + TIMEOUT = 401, + TYPE = 'success', +} + +/** + * @description: request method + */ +export enum RequestEnum { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', +} + +/** + * @description: contentTyp + */ +export enum ContentTypeEnum { + // 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/src/enums/menuEnum.ts b/src/enums/menuEnum.ts new file mode 100644 index 000000000..2c2234bb2 --- /dev/null +++ b/src/enums/menuEnum.ts @@ -0,0 +1,40 @@ +/** + * @description: menu type + */ +export enum MenuTypeEnum { + // left menu + SIDEBAR = 'sidebar', + // mixin menu + MIX = 'mix', + // top menu + TOP_MENU = 'top-menu', +} + +// menu theme enum +export enum MenuThemeEnum { + DARK = 'dark', + + LIGHT = 'light', +} + +export type Mode = 'vertical' | 'vertical-right' | 'horizontal' | 'inline'; + +// menu mode +export enum MenuModeEnum { + VERTICAL = 'vertical', + HORIZONTAL = 'horizontal', + VERTICAL_RIGHT = 'vertical-right', + INLINE = 'inline', +} + +export enum MenuSplitTyeEnum { + NONE, + TOP, + LEFT, +} + +export enum TopMenuAlignEnum { + CENTER = 'center', + START = 'start', + END = 'end', +} diff --git a/src/enums/pageEnum.ts b/src/enums/pageEnum.ts new file mode 100644 index 000000000..136fa46cf --- /dev/null +++ b/src/enums/pageEnum.ts @@ -0,0 +1,8 @@ +export enum PageEnum { + // basic login path + BASE_LOGIN = '/login', + // basic home path + BASE_HOME = '/dashboard', + // error page path + ERROR_PAGE = '/exception', +} diff --git a/src/enums/paginationEnum.ts b/src/enums/paginationEnum.ts new file mode 100644 index 000000000..2d2a2b051 --- /dev/null +++ b/src/enums/paginationEnum.ts @@ -0,0 +1,4 @@ +export enum PaginationEnum { + // Default number of pages + DEFAULT_PAGE_SIZE = 20, +} diff --git a/src/enums/resultEnum.ts b/src/enums/resultEnum.ts new file mode 100644 index 000000000..f96894cf9 --- /dev/null +++ b/src/enums/resultEnum.ts @@ -0,0 +1,6 @@ +export enum ResultEnum { + SUCCESS = 0, + ERROR = 1, + TIMEOUT = 401, + TYPE = 'success', +} diff --git a/src/enums/roleEnum.ts b/src/enums/roleEnum.ts new file mode 100644 index 000000000..857868d59 --- /dev/null +++ b/src/enums/roleEnum.ts @@ -0,0 +1,7 @@ +export enum RoleEnum { + // super admin + SUPER = 'super', + + // tester + TEST = 'test', +} diff --git a/src/enums/sizeEnum.ts b/src/enums/sizeEnum.ts new file mode 100644 index 000000000..9bac18437 --- /dev/null +++ b/src/enums/sizeEnum.ts @@ -0,0 +1,19 @@ +export enum SizeEnum { + DEFAULT = 'default', + SMALL = 'small', + LARGE = 'large', +} + +export enum SizeNumberEnum { + DEFAULT = 48, + SMALL = 16, + LARGE = 64, +} + +export const sizeMap: Map = (() => { + const map = new Map(); + map.set(SizeEnum.DEFAULT, SizeNumberEnum.DEFAULT); + map.set(SizeEnum.SMALL, SizeNumberEnum.SMALL); + map.set(SizeEnum.LARGE, SizeNumberEnum.LARGE); + return map; +})(); diff --git a/src/hooks/core/types.ts b/src/hooks/core/types.ts new file mode 100644 index 000000000..4d8324b55 --- /dev/null +++ b/src/hooks/core/types.ts @@ -0,0 +1,92 @@ +import type { VNode, Ref } from 'vue'; +import type { ModalOptions } from 'ant-design-vue/types/modal'; + +export type Fn = () => T; +export type AnyFn = (...arg: any) => T; +export type PromiseFn = (...arg: any) => Promise; +export type CancelFn = () => void; +export interface DebounceAndThrottleOptions { + // 立即执行 + immediate?: boolean; + + // 是否为debounce + debounce?: boolean; + // 只执行一次 + once?: boolean; +} + +export type DebounceAndThrottleProcedure = (...args: T) => unknown; + +export type DebounceAndThrottleProcedureResult = [ + DebounceAndThrottleProcedure, + CancelFn +]; + +export type TimeoutResult = [Ref, Fn, Fn]; + +export type TimeoutFnResult = [Fn, Fn, Ref]; + +export interface PromiseState { + loading: boolean; + error: Error | null; + result: any; + done: boolean; +} +export type MessageType = 'success' | 'warning' | 'info' | 'error'; + +export interface CloseEventHandler { + /** + * Triggers when a message is being closed + * + * @param instance The message component that is being closed + */ + (instance: MessageComponent): void; +} + +/** Message Component */ +export declare class MessageComponent { + /** Close the Loading instance */ + close(): void; +} + +export type MessageMethods = { + [key in MessageType]?: (options: MessageOptions | string) => MessageComponent; // Note that "key in". +}; + +/** Options used in Message */ +export interface MessageOptions { + title: string; + /** Message text */ + message: string | VNode; + + /** Message type */ + type?: MessageType; + + /** Custom icon's class, overrides type */ + iconClass?: string; + + /** Custom class name for Message */ + customClass?: string; + + /** Display duration, millisecond. If set to 0, it will not turn off automatically */ + duration?: number; + + /** Whether to show a close button */ + showClose?: boolean; + + /** Whether to center the text */ + center?: boolean; + + /** Whether message is treated as HTML string */ + dangerouslyUseHTMLString?: boolean; + + /** Callback function when closed with the message instance as the parameter */ + onClose?: CloseEventHandler; + + /** Set the distance to the top of viewport. Default is 20 px. */ + offset?: number; +} +export interface ModalOptionsEx extends Omit { + iconType: 'warning' | 'success' | 'error'; +} +export type ModalOptionsPartial = Partial & Pick; diff --git a/src/hooks/core/useDebounce.ts b/src/hooks/core/useDebounce.ts new file mode 100644 index 000000000..028e3e93b --- /dev/null +++ b/src/hooks/core/useDebounce.ts @@ -0,0 +1,26 @@ +import type { + DebounceAndThrottleOptions, + DebounceAndThrottleProcedureResult, + DebounceAndThrottleProcedure, +} from './types'; +import { + // throttle, + useThrottle, +} from './useThrottle'; + +/** + * @description: Applicable in components + */ +export function useDebounce( + handle: DebounceAndThrottleProcedure, + wait: number, + options: DebounceAndThrottleOptions = {} +): DebounceAndThrottleProcedureResult { + return useThrottle( + handle, + wait, + Object.assign(options, { + debounce: true, + }) + ); +} diff --git a/src/hooks/core/useSetting.ts b/src/hooks/core/useSetting.ts new file mode 100644 index 000000000..3b53e93d0 --- /dev/null +++ b/src/hooks/core/useSetting.ts @@ -0,0 +1,19 @@ +import type { ProjectConfig, GlobConfig, SettingWrap } from '/@/types/config'; + +import getProjectSetting from '/@/settings/projectSetting'; + +export const useSetting = (): SettingWrap => { + // Take global configuration + const glob: Readonly = { + title: 'vben admin 2.0', + apiUrl: '/api', + shortName: 'vben_admin_v2', + urlPrefix: '', + }; + const projectSetting: Readonly = getProjectSetting; + + return { + globSetting: glob as Readonly, + projectSetting, + }; +}; diff --git a/src/hooks/core/useThrottle.ts b/src/hooks/core/useThrottle.ts new file mode 100644 index 000000000..d1ef8255d --- /dev/null +++ b/src/hooks/core/useThrottle.ts @@ -0,0 +1,72 @@ +import type { + DebounceAndThrottleOptions, + DebounceAndThrottleProcedureResult, + DebounceAndThrottleProcedure, +} from './types'; + +import { isFunction } from '/@/utils/is'; +export function throttle( + handle: DebounceAndThrottleProcedure, + wait: number, + options: DebounceAndThrottleOptions = {} +): DebounceAndThrottleProcedureResult { + if (!isFunction(handle)) { + throw new Error('handle is not Function!'); + } + let { immediate = false } = options; + const { once = false, debounce = false } = options; + let timeoutId: ReturnType | undefined; + // Has it been cancelled + let cancelled: boolean | null = false; + /** + * @description: clear timer + */ + function clearTimer() { + if (timeoutId) { + window.clearTimeout(timeoutId); + timeoutId = undefined; + } + } + /** cancel exec */ + function cancel() { + clearTimer(); + cancelled = true; + } + // If once is true, only execute once + function cancelExec(): void { + once && cancel(); + } + function fn(this: unknown, ...args: T) { + // If it has been cancelled, it will not be executed + if (cancelled) { + return; + } + const exec = () => { + !debounce && clearTimer(); + handle.apply(this, args); + cancelExec(); + }; + if (immediate) { + immediate = false; + const callNow = !timeoutId; + if (callNow) { + exec(); + timeoutId = undefined; + } + } else { + debounce && clearTimer(); + if (!timeoutId || debounce) { + timeoutId = setTimeout(exec, wait); + } + } + } + return [fn, cancel]; +} + +export function useThrottle( + handle: DebounceAndThrottleProcedure, + wait: number, + options: DebounceAndThrottleOptions = {} +): DebounceAndThrottleProcedureResult { + return throttle(handle, wait, options); +} diff --git a/src/hooks/core/useTimeout.ts b/src/hooks/core/useTimeout.ts new file mode 100644 index 000000000..d8dca5f32 --- /dev/null +++ b/src/hooks/core/useTimeout.ts @@ -0,0 +1,23 @@ +import type { TimeoutFnResult, Fn } from './types'; + +import { isFunction } from '/@/utils/is'; +import { watch } from 'vue'; + +import { useTimeoutRef } from '/@/hooks/core/useTimeoutRef'; + +export function useTimeout(handle: Fn, wait: number): TimeoutFnResult { + if (!isFunction(handle)) { + throw new Error('handle is not Function!'); + } + + const [readyRef, clear, runAgain] = useTimeoutRef(wait); + + watch( + readyRef, + (maturity) => { + maturity && handle(); + }, + { immediate: false } + ); + return [clear, runAgain, readyRef]; +} diff --git a/src/hooks/core/useTimeoutRef.ts b/src/hooks/core/useTimeoutRef.ts new file mode 100644 index 000000000..e8e9254d8 --- /dev/null +++ b/src/hooks/core/useTimeoutRef.ts @@ -0,0 +1,25 @@ +import type { TimeoutResult } from './types'; + +import { ref } from 'vue'; +import { tryOnUnmounted } from '/@/utils/helper/vueHelper'; +export function useTimeoutRef(wait: number): TimeoutResult { + const readyRef = ref(false); + + let timer: ReturnType | undefined; + function clear(): void { + readyRef.value = false; + timer && window.clearTimeout(timer); + } + function openTimer(): void { + clear(); + timer = setTimeout(() => { + readyRef.value = true; + }, wait); + } + + openTimer(); + + tryOnUnmounted(clear); + + return [readyRef, clear, openTimer]; +} diff --git a/src/hooks/event/types.ts b/src/hooks/event/types.ts new file mode 100644 index 000000000..404e1e735 --- /dev/null +++ b/src/hooks/event/types.ts @@ -0,0 +1 @@ +export type Fn = () => T; diff --git a/src/hooks/event/useBreakpoint.ts b/src/hooks/event/useBreakpoint.ts new file mode 100644 index 000000000..7fcb67a19 --- /dev/null +++ b/src/hooks/event/useBreakpoint.ts @@ -0,0 +1,65 @@ +import { ref, computed, Ref, unref } from 'vue'; +import { useEvent } from './useEvent'; +import { screenMap, sizeEnum, screenEnum } from '/@/enums/breakpointEnum'; + +let globalScreenRef: Ref; +let globalWidthRef: Ref; +let globalRealWidthRef: Ref; + +export function useBreakpoint() { + return { + screenRef: computed(() => unref(globalScreenRef)), + widthRef: globalWidthRef, + screenEnum, + realWidthRef: globalRealWidthRef, + }; +} + +// 只要调用一次即可 +export function createBreakpointListen(fn?: Fn) { + const screenRef = ref(sizeEnum.XL); + const realWidthRef = ref(window.innerWidth); + + function getWindowWidth() { + const width = document.body.clientWidth; + const xs = screenMap.get(sizeEnum.XS)!; + const sm = screenMap.get(sizeEnum.SM)!; + const md = screenMap.get(sizeEnum.MD)!; + const lg = screenMap.get(sizeEnum.LG)!; + const xl = screenMap.get(sizeEnum.XL)!; + if (width < xs) { + screenRef.value = sizeEnum.XS; + } else if (width < sm) { + screenRef.value = sizeEnum.SM; + } else if (width < md) { + screenRef.value = sizeEnum.MD; + } else if (width < lg) { + screenRef.value = sizeEnum.LG; + } else if (width < xl) { + screenRef.value = sizeEnum.XL; + } else { + screenRef.value = sizeEnum.XXL; + } + realWidthRef.value = width; + } + + useEvent({ + el: window, + name: 'resize', + listener: () => { + fn && fn(); + getWindowWidth(); + }, + }); + + getWindowWidth(); + globalScreenRef = computed(() => unref(screenRef)); + globalWidthRef = computed((): number => screenMap.get(unref(screenRef)!)!); + globalRealWidthRef = computed((): number => unref(realWidthRef)); + return { + screenRef: globalScreenRef, + screenEnum, + widthRef: globalWidthRef, + realWidthRef: globalRealWidthRef, + }; +} diff --git a/src/hooks/event/useElResize.ts b/src/hooks/event/useElResize.ts new file mode 100644 index 000000000..367cc1519 --- /dev/null +++ b/src/hooks/event/useElResize.ts @@ -0,0 +1,30 @@ +import { useDebounce } from '/@/hooks/core/useDebounce'; +import { addResizeListener, removeResizeListener } from '/@/utils/event/resizeEvent'; + +interface WindowSizeOptions { + once?: boolean; + immediate?: boolean; +} + +export function useElResize( + el: Element | typeof window, + fn: Fn, + wait = 100, + options?: WindowSizeOptions +) { + let handler = () => { + fn(); + }; + const [handleSize, cancel] = useDebounce(handler, wait, options); + handler = wait ? handleSize : handler; + + function start() { + addResizeListener(el, handler); + } + function stop() { + removeResizeListener(el, handler); + cancel(); + } + + return [start, stop]; +} diff --git a/src/hooks/event/useEvent.ts b/src/hooks/event/useEvent.ts new file mode 100644 index 000000000..7f3dfb0db --- /dev/null +++ b/src/hooks/event/useEvent.ts @@ -0,0 +1,63 @@ +import type { Ref } from 'vue'; + +import { ref, watch, unref } from 'vue'; +import { useDebounce } from '/@/hooks/core/useDebounce'; +import { useThrottle } from '/@/hooks/core/useThrottle'; + +export type RemoveEventFn = () => void; + +export interface UseEventParams { + el?: Element | Ref | Window | any; + name: string; + listener: EventListener; + options?: boolean | AddEventListenerOptions; + autoRemove?: boolean; + isDebounce?: boolean; + wait?: number; +} +export function useEvent({ + el = window, + name, + listener, + options, + autoRemove = true, + isDebounce = true, + wait = 80, +}: UseEventParams): { removeEvent: RemoveEventFn } { + /* eslint-disable-next-line */ + let remove: RemoveEventFn = () => {}; + const isAddRef = ref(false); + + if (el) { + const element: Ref = ref(el as Element); + + const [handler] = isDebounce ? useDebounce(listener, wait) : useThrottle(listener, wait); + const realHandler = wait ? handler : listener; + const removeEventListener = (e: Element) => { + isAddRef.value = true; + e.removeEventListener(name, realHandler, options); + }; + const addEventListener = (e: Element) => e.addEventListener(name, realHandler, options); + + const removeWatch = watch( + element, + (v, ov, cleanUp) => { + if (v) { + !unref(isAddRef) && addEventListener(v); + cleanUp(() => { + // @ts-ignore + window.a = v; + autoRemove && removeEventListener(v); + }); + } + }, + { immediate: true } + ); + + remove = () => { + removeEventListener(element.value); + removeWatch(); + }; + } + return { removeEvent: remove }; +} diff --git a/src/hooks/event/useRaf.ts b/src/hooks/event/useRaf.ts new file mode 100644 index 000000000..61575fc01 --- /dev/null +++ b/src/hooks/event/useRaf.ts @@ -0,0 +1,95 @@ +import { isServer } from '/@/utils/is'; +import { onUnmounted, getCurrentInstance } from 'vue'; +let lastTime = 0; +const prefixes = 'webkit moz ms o'.split(' '); // Each browser prefix + +let requestAnimationFrame: any; +let cancelAnimationFrame: any; + +/* eslint-disable-next-line */ +const NO_LOOP = () => {}; + +const getWindowFrame = (name: string) => { + return name as any; +}; +if (isServer) { + requestAnimationFrame = cancelAnimationFrame = NO_LOOP; +} else { + requestAnimationFrame = window.requestAnimationFrame; + cancelAnimationFrame = window.cancelAnimationFrame; + let prefix; + for (let i = 0; i < prefixes.length; i++) { + if (requestAnimationFrame && cancelAnimationFrame) { + break; + } + prefix = prefixes[i]; + requestAnimationFrame = + requestAnimationFrame || window[getWindowFrame(prefix + 'RequestAnimationFrame')]; + cancelAnimationFrame = + cancelAnimationFrame || + window[getWindowFrame(prefix + 'CancelAnimationFrame')] || + window[getWindowFrame(prefix + 'CancelRequestAnimationFrame')]; + } + + // If the current browser does not support requestAnimationFrame and cancelAnimationFrame, it will fall back to setTimeout + if (!requestAnimationFrame || !cancelAnimationFrame) { + requestAnimationFrame = function (callback: Fn) { + const currTime = new Date().getTime(); + const timeToCall = Math.max(0, 16 - (currTime - lastTime)); + const id = window.setTimeout(() => { + /* eslint-disable-next-line */ + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + + cancelAnimationFrame = function (id: number) { + window.clearTimeout(id); + }; + } +} + +export function useRaf() { + if (getCurrentInstance()) { + onUnmounted(() => { + cancelAnimationFrame(); + }); + } + return { requestAnimationFrame }; +} + +export function useRafFn(fn: () => any, options: { immediate?: boolean } = {}) { + const { immediate = false } = options; + let started = false; + + function loop() { + if (!started) return; + fn(); + requestAnimationFrame(loop); + } + + function start() { + if (!started) { + started = true; + loop(); + } + } + + function stop() { + started = false; + } + + if (immediate) { + start(); + } + + if (getCurrentInstance()) { + onUnmounted(() => { + cancelAnimationFrame(); + stop(); + }); + } + + return { stop, start }; +} diff --git a/src/hooks/event/useScroll.ts b/src/hooks/event/useScroll.ts new file mode 100644 index 000000000..24a746983 --- /dev/null +++ b/src/hooks/event/useScroll.ts @@ -0,0 +1,66 @@ +import type { Ref } from 'vue'; + +import { ref, onMounted, watch, onUnmounted } from 'vue'; +import { useThrottle } from '/@/hooks/core/useThrottle'; +import { isWindow, isObject } from '/@/utils/is'; + +export function useScroll( + refEl: Ref, + options?: { + wait?: number; + leading?: boolean; + trailing?: boolean; + } +) { + const refX = ref(0); + const refY = ref(0); + let handler = () => { + if (isWindow(refEl.value)) { + refX.value = refEl.value.scrollX; + refY.value = refEl.value.scrollY; + } else if (refEl.value) { + refX.value = (refEl.value as Element).scrollLeft; + refY.value = (refEl.value as Element).scrollTop; + } + }; + + if (isObject(options)) { + let wait = 0; + if (options.wait && options.wait > 0) { + wait = options.wait; + Reflect.deleteProperty(options, 'wait'); + } + + const [throttleHandle] = useThrottle(handler, wait); + handler = throttleHandle; + } + + let stopWatch: () => void; + onMounted(() => { + stopWatch = watch( + refEl, + (el, prevEl, onCleanup) => { + if (el) { + el.addEventListener('scroll', handler); + } else if (prevEl) { + prevEl.removeEventListener('scroll', handler); + } + onCleanup(() => { + refX.value = refY.value = 0; + el && el.removeEventListener('scroll', handler); + }); + }, + { immediate: true } + ); + }); + + onUnmounted(() => { + refEl.value && refEl.value.removeEventListener('scroll', handler); + }); + + function stop() { + stopWatch && stopWatch(); + } + + return { refX, refY, stop }; +} diff --git a/src/hooks/event/useScrollTo.ts b/src/hooks/event/useScrollTo.ts new file mode 100644 index 000000000..eaa44e2d2 --- /dev/null +++ b/src/hooks/event/useScrollTo.ts @@ -0,0 +1,60 @@ +import { useRaf } from '/@/hooks/event/useRaf'; +import { isFunction, isUnDef } from '/@/utils/is'; +import { ref, unref } from 'vue'; +export interface ScrollToParams { + el: HTMLElement; + to: number; + duration?: number; + callback?: () => any; +} + +const easeInOutQuad = (t: number, b: number, c: number, d: number) => { + t /= d / 2; + if (t < 1) { + return (c / 2) * t * t + b; + } + t--; + return (-c / 2) * (t * (t - 2) - 1) + b; +}; +const move = (el: HTMLElement, amount: number) => { + el.scrollTop = amount; +}; + +const position = (el: HTMLElement) => { + return el.scrollTop; +}; +export function useScrollTo({ el, to, duration = 500, callback }: ScrollToParams) { + const isActiveRef = ref(false); + const start = position(el); + const change = to - start; + const increment = 20; + let currentTime = 0; + duration = isUnDef(duration) ? 500 : duration; + const { requestAnimationFrame } = useRaf(); + + const animateScroll = function () { + if (!unref(isActiveRef)) { + return; + } + currentTime += increment; + const val = easeInOutQuad(currentTime, start, change, duration); + move(el, val); + if (currentTime < duration && unref(isActiveRef)) { + requestAnimationFrame(animateScroll); + } else { + if (callback && isFunction(callback)) { + callback(); + } + } + }; + const run = () => { + isActiveRef.value = true; + animateScroll(); + }; + + const stop = () => { + isActiveRef.value = false; + }; + + return { start: run, stop }; +} diff --git a/src/hooks/event/useWindowSize.ts b/src/hooks/event/useWindowSize.ts new file mode 100644 index 000000000..91bd1d644 --- /dev/null +++ b/src/hooks/event/useWindowSize.ts @@ -0,0 +1,47 @@ +import type { Fn } from './types'; + +import { tryOnMounted, tryOnUnmounted } from '/@/utils/helper/vueHelper'; +import { ref } from 'vue'; + +import { useDebounce } from '/@/hooks/core/useDebounce'; + +interface WindowSizeOptions { + once?: boolean; + immediate?: boolean; + listenerOptions?: AddEventListenerOptions | boolean; +} + +export function useWindowSizeFn(fn: Fn, wait = 150, options?: WindowSizeOptions): void { + let handler = () => { + fn(); + }; + const [handleSize, cancel] = useDebounce(handler, wait, options); + handler = handleSize; + + tryOnMounted(() => { + window.addEventListener('resize', handler); + }); + tryOnUnmounted(() => { + window.removeEventListener('resize', handler); + cancel(); + }); +} + +export const useWindowSize = (wait = 150, options?: WindowSizeOptions) => { + const widthRef = ref(0); + const heightRef = ref(0); + + function setSize() { + widthRef.value = window.innerWidth; + heightRef.value = window.innerHeight; + } + setSize(); + + const handler = () => { + setSize(); + }; + + useWindowSizeFn(handler, wait, options); + + return { widthRef: widthRef, heightRef: heightRef }; +}; diff --git a/src/hooks/state/useGlobalState.ts b/src/hooks/state/useGlobalState.ts new file mode 100644 index 000000000..df3d13623 --- /dev/null +++ b/src/hooks/state/useGlobalState.ts @@ -0,0 +1,12 @@ +import { reactive } from 'vue'; + +export function createGlobalState(factory: () => T) { + let state: T; + + return () => { + if (!state) { + state = reactive(factory()) as T; + } + return state; + }; +} diff --git a/src/hooks/web/useClickOutside.ts b/src/hooks/web/useClickOutside.ts new file mode 100644 index 000000000..0979502c8 --- /dev/null +++ b/src/hooks/web/useClickOutside.ts @@ -0,0 +1,32 @@ +import { ref, Ref, unref } from 'vue'; +import { useEvent } from '/@/hooks/event/useEvent'; +export function useClickOutside( + containerRef: Ref, + onClickOutside: (e: MouseEvent | TouchEvent) => void +) { + const isTouchRef = ref(false); + useEvent({ + el: document, + name: 'touchend', + listener: handler, + options: true, + }); + useEvent({ + el: document, + name: 'click', + listener: handler, + options: true, + }); + + function handler(e: MouseEvent | TouchEvent) { + if (e.type === 'touchend') { + isTouchRef.value = true; + } + if (e.type === 'click' && unref(isTouchRef)) return; + + const el = containerRef.value; + if (el && e.target && !el.contains(e.target as Node)) { + onClickOutside(e); + } + } +} diff --git a/src/hooks/web/useContextMenu.ts b/src/hooks/web/useContextMenu.ts new file mode 100644 index 000000000..dceca0a31 --- /dev/null +++ b/src/hooks/web/useContextMenu.ts @@ -0,0 +1,12 @@ +import { onUnmounted, getCurrentInstance } from 'vue'; +import { createContextMenu, unMountedContextMenu } from '/@/components/ContextMenu'; +import type { ContextMenuItem } from '/@/components/ContextMenu'; +export type { ContextMenuItem }; +export function useContextMenu(authRemove = true) { + if (getCurrentInstance() && authRemove) { + onUnmounted(() => { + unMountedContextMenu(); + }); + } + return [createContextMenu, unMountedContextMenu]; +} diff --git a/src/hooks/web/useCopyToClipboard.ts b/src/hooks/web/useCopyToClipboard.ts new file mode 100644 index 000000000..7a7fea800 --- /dev/null +++ b/src/hooks/web/useCopyToClipboard.ts @@ -0,0 +1,69 @@ +import { ref, watch } from 'vue'; + +import { isDef } from '/@/utils/is'; +interface Options { + target?: HTMLElement; +} +export function useCopyToClipboard(initial?: string) { + const clipboardRef = ref(initial || ''); + const isSuccessRef = ref(false); + const copiedRef = ref(false); + + watch( + clipboardRef, + (str?: string) => { + if (isDef(str)) { + copiedRef.value = true; + isSuccessRef.value = copyTextToClipboard(str); + } + }, + { immediate: !!initial, flush: 'sync' } + ); + + return { clipboardRef, isSuccessRef, copiedRef }; +} + +export function copyTextToClipboard(input: string, { target = document.body }: Options = {}) { + const element = document.createElement('textarea'); + const previouslyFocusedElement = document.activeElement; + + element.value = input; + + element.setAttribute('readonly', ''); + + (element.style as any).contain = 'strict'; + element.style.position = 'absolute'; + element.style.left = '-9999px'; + element.style.fontSize = '12pt'; + + const selection = document.getSelection(); + let originalRange; + if (selection && selection.rangeCount > 0) { + originalRange = selection.getRangeAt(0); + } + + target.append(element); + element.select(); + + element.selectionStart = 0; + element.selectionEnd = input.length; + + let isSuccess = false; + try { + isSuccess = document.execCommand('copy'); + } catch (e) { + throw new Error(e); + } + + element.remove(); + + if (originalRange && selection) { + selection.removeAllRanges(); + selection.addRange(originalRange); + } + + if (previouslyFocusedElement) { + (previouslyFocusedElement as HTMLElement).focus(); + } + return isSuccess; +} diff --git a/src/hooks/web/useCssVar.ts b/src/hooks/web/useCssVar.ts new file mode 100644 index 000000000..a7a57c4f8 --- /dev/null +++ b/src/hooks/web/useCssVar.ts @@ -0,0 +1,34 @@ +import { ref, Ref, isRef, watch } from '@vue/runtime-dom' + +export default function useCssVar( + prop: string, + refEl?: Ref +) { + const refVar = ref('') + let el: HTMLElement = document.documentElement + + if (isRef(refEl)) { + watch( + refEl, + () => { + if (refEl.value) { + el = refEl.value as HTMLElement + refVar.value = getComputedStyle(el).getPropertyValue(prop) + } + }, + { immediate: true } + ) + } else { + refVar.value = getComputedStyle(el).getPropertyValue(prop) + } + + watch( + refVar, + val => { + el && el.style.setProperty(prop, val) + }, + { immediate: true } + ) + + return refVar +} diff --git a/src/hooks/web/useFullContent.ts b/src/hooks/web/useFullContent.ts new file mode 100644 index 000000000..73a0c66df --- /dev/null +++ b/src/hooks/web/useFullContent.ts @@ -0,0 +1,24 @@ +import { computed, unref } from 'vue'; + +import { appStore } from '/@/store/modules/app'; + +import { useRouter } from 'vue-router'; +/** + * @description: Full screen display content + */ +export const useFullContent = () => { + const { currentRoute } = useRouter(); + + // Whether to display the content in full screen without displaying the menu + const getFullContent = computed(() => { + // Query parameters, the full screen is displayed when the address bar has a full parameter + const route = unref(currentRoute); + const query = route.query; + if (query && Reflect.has(query, '__full__')) { + return true; + } + // Return to the configuration in the configuration file + return appStore.getProjectConfig.fullContent; + }); + return { getFullContent }; +}; diff --git a/src/hooks/web/useFullScreen.ts b/src/hooks/web/useFullScreen.ts new file mode 100644 index 000000000..73999b339 --- /dev/null +++ b/src/hooks/web/useFullScreen.ts @@ -0,0 +1,104 @@ +import { Ref, ref, unref } from 'vue'; + +type RFSMethodName = + | 'webkitRequestFullScreen' + | 'requestFullscreen' + | 'msRequestFullscreen' + | 'mozRequestFullScreen'; +type EFSMethodName = + | 'webkitExitFullscreen' + | 'msExitFullscreen' + | 'mozCancelFullScreen' + | 'exitFullscreen'; +type FSEPropName = + | 'webkitFullscreenElement' + | 'msFullscreenElement' + | 'mozFullScreenElement' + | 'fullscreenElement'; +type ONFSCPropName = + | 'onfullscreenchange' + | 'onwebkitfullscreenchange' + | 'onmozfullscreenchange' + | 'MSFullscreenChange'; + +export function useFullscreen( + target: Ref> = ref(document.documentElement), + options?: FullscreenOptions +) { + const isFullscreenRef = ref(false); + const DOC_EL = document.documentElement; + let RFC_METHOD_NAME: RFSMethodName = 'requestFullscreen'; + let EFS_METHOD_NAME: EFSMethodName = 'exitFullscreen'; + let FSE_PROP_NAME: FSEPropName = 'fullscreenElement'; + let ON_FSC_PROP_NAME: ONFSCPropName = 'onfullscreenchange'; + + if ('webkitRequestFullScreen' in DOC_EL) { + RFC_METHOD_NAME = 'webkitRequestFullScreen'; + EFS_METHOD_NAME = 'webkitExitFullscreen'; + FSE_PROP_NAME = 'webkitFullscreenElement'; + ON_FSC_PROP_NAME = 'onwebkitfullscreenchange'; + } else if ('msRequestFullscreen' in DOC_EL) { + RFC_METHOD_NAME = 'msRequestFullscreen'; + EFS_METHOD_NAME = 'msExitFullscreen'; + FSE_PROP_NAME = 'msFullscreenElement'; + ON_FSC_PROP_NAME = 'MSFullscreenChange'; + } else if ('mozRequestFullScreen' in DOC_EL) { + RFC_METHOD_NAME = 'mozRequestFullScreen'; + EFS_METHOD_NAME = 'mozCancelFullScreen'; + FSE_PROP_NAME = 'mozFullScreenElement'; + ON_FSC_PROP_NAME = 'onmozfullscreenchange'; + } else if (!('requestFullscreen' in DOC_EL)) { + throw new Error('当前浏览器不支持Fullscreen API !'); + } + function enterFullscreen(): Promise { + return (target.value as any)[RFC_METHOD_NAME](options); + } + + function exitFullscreen(): Promise { + return (document as any)[EFS_METHOD_NAME](); + } + + function isFullscreen(): boolean { + return unref(target) === (document as any)[FSE_PROP_NAME]; + } + + function toggleFullscreen(): Promise { + if (isFullscreen()) { + return exitFullscreen(); + } else { + return enterFullscreen(); + } + } + + /** + * 当全屏/退出时触发 + */ + function watchFullscreen(callback: (isFull: boolean) => void) { + const cancel = () => { + const t = unref(target); + t && (t.onfullscreenchange = null); + }; + + const handler = () => { + callback(isFullscreen()); + }; + if (target.value) { + (target.value as any)[ON_FSC_PROP_NAME] = handler; + } + + return { + cancel, + }; + } + watchFullscreen((isFull: boolean) => { + isFullscreenRef.value = isFull; + }); + return { + watchFullscreen, + toggleFullscreen, + exitFullscreen, + isFullscreen, + enterFullscreen, + isFullscreenRef, + }; +} diff --git a/src/hooks/web/useI18n.ts b/src/hooks/web/useI18n.ts new file mode 100644 index 000000000..db9f45aa3 --- /dev/null +++ b/src/hooks/web/useI18n.ts @@ -0,0 +1,16 @@ +import { createI18n } from 'vue-i18n'; +import { ref, watch } from 'vue'; +import type { I18nOptions } from 'vue-i18n'; +export function useI18n(options?: I18nOptions) { + const i18n = createI18n(options); + + const localeRef = ref(i18n.global.locale); + + watch(localeRef, () => { + i18n.global.locale = localeRef.value as any; + }); + return { + t: i18n.global.t, + localeRef, + }; +} diff --git a/src/hooks/web/useLocalStorage.ts b/src/hooks/web/useLocalStorage.ts new file mode 100644 index 000000000..59dfc2316 --- /dev/null +++ b/src/hooks/web/useLocalStorage.ts @@ -0,0 +1,5 @@ +import { createStorage } from '/@/utils/storage/index'; + +export function useLocalStorage() { + return createStorage(localStorage); +} diff --git a/src/hooks/web/useLockPage.ts b/src/hooks/web/useLockPage.ts new file mode 100644 index 000000000..bf923d6ed --- /dev/null +++ b/src/hooks/web/useLockPage.ts @@ -0,0 +1,57 @@ +import { onUnmounted, watchEffect } from 'vue'; +import { useThrottle } from '/@/hooks/core/useThrottle'; + +import { appStore } from '/@/store/modules/app'; +import { userStore } from '/@/store/modules/user'; +export function useLockPage() { + let timeId: ReturnType; + + function clear() { + window.clearTimeout(timeId); + } + function resetCalcLockTimeout() { + // not login + if (!userStore.getTokenState) { + clear(); + return; + } + const lockTime = appStore.getProjectConfig.lockTime; + if (!lockTime || lockTime < 1) { + clear(); + return; + } + clear(); + + timeId = setTimeout(() => { + lockPage(); + }, lockTime * 60 * 1000); + } + + function lockPage() { + appStore.commitLockInfoState({ + isLock: true, + pwd: undefined, + }); + } + + watchEffect(() => { + if (userStore.getTokenState) { + resetCalcLockTimeout(); + } else { + clear(); + } + }); + onUnmounted(() => { + clear(); + }); + const [keyupFn] = useThrottle(resetCalcLockTimeout, 2000); + + return { + registerGlobOnKeyup: keyupFn, + registerGlobOnMouseMove: keyupFn, + on: { + onKeyup: keyupFn, + onMousemove: keyupFn, + }, + }; +} diff --git a/src/hooks/web/useMessage.tsx b/src/hooks/web/useMessage.tsx new file mode 100644 index 000000000..cca168854 --- /dev/null +++ b/src/hooks/web/useMessage.tsx @@ -0,0 +1,81 @@ +import type { ModalOptionsEx, ModalOptionsPartial } from '/@/hooks/core/types'; + +import { Modal, message as Message, notification } from 'ant-design-vue'; +import { InfoCircleFilled, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons-vue'; +import { ModalOptions, ModalConfirm } from 'ant-design-vue/types/modal'; + +import { useSetting } from '/@/hooks/core/useSetting'; + +const { projectSetting } = useSetting(); + +function getIcon(iconType: string) { + if (iconType === 'warning') { + return ; + } else if (iconType === 'success') { + return ; + } else { + return ; + } +} +function renderContent({ content }: Pick) { + return
    ${content}
    `}>
    ; +} + +/** + * @description: Create confirmation box + */ +function createConfirm(options: ModalOptionsEx): ModalConfirm { + const iconType = options.iconType || 'warning'; + Reflect.deleteProperty(options, 'iconType'); + const opt: ModalOptions = { + centered: true, + icon: getIcon(iconType), + ...projectSetting.messageSetting, + ...options, + }; + return Modal.confirm(opt); +} +const baseOptions = { + okText: '确定', + centered: true, +}; + +function createModalOptions(options: ModalOptionsPartial, icon: string): ModalOptionsPartial { + return { + ...baseOptions, + ...options, + content: renderContent(options), + icon: getIcon(icon), + }; +} +function createSuccessModal(options: ModalOptionsPartial) { + return Modal.success(createModalOptions(options, 'check')); +} +function createErrorModal(options: ModalOptionsPartial) { + return Modal.error(createModalOptions(options, 'close')); +} +function createInfoModal(options: ModalOptionsPartial) { + return Modal.info(createModalOptions(options, 'info')); +} +function createWarningModal(options: ModalOptionsPartial) { + return Modal.warning(createModalOptions(options, 'info')); +} + +notification.config({ + placement: 'topRight', + duration: 3, +}); +/** + * @description: message + */ +export function useMessage() { + return { + createMessage: Message, + notification, + createConfirm: createConfirm, + createSuccessModal, + createErrorModal, + createInfoModal, + createWarningModal, + }; +} diff --git a/src/hooks/web/useNetWork.ts b/src/hooks/web/useNetWork.ts new file mode 100644 index 000000000..436b55f60 --- /dev/null +++ b/src/hooks/web/useNetWork.ts @@ -0,0 +1,53 @@ +import type { Ref } from 'vue'; + +import { ref, watch } from 'vue'; +import { tryOnMounted, tryOnUnmounted } from '/@/utils/helper/vueHelper'; + +import { isBoolean } from '/@/utils/is'; + +const ON_LINE = 'online'; +const OFF_LINE = 'offline'; +export function useNetWork({ + onLineFn, + offLineFn, +}: { + onLineFn?: () => void; + offLineFn?: () => void; +}) { + const onLineRef = ref(navigator.onLine); + + // Disconnect time + const offlineAt: Ref = ref(undefined); + + watch( + () => onLineRef.value, + (onLine, oldValue): void => { + if (isBoolean(oldValue) && !oldValue && onLine) { + onLineFn && onLineFn(); + } else if (isBoolean(onLine) && !onLine && oldValue) { + // Network to no network + offlineAt.value = Date.now(); + offLineFn && offLineFn(); + } + }, + { + immediate: false, + } + ); + + const handler = (e: Event) => { + const { type } = e; + onLineRef.value = type === ON_LINE; + }; + tryOnMounted(() => { + window.addEventListener(ON_LINE, handler); + window.addEventListener(OFF_LINE, handler); + }); + tryOnUnmounted(() => { + window.removeEventListener(ON_LINE, handler); + window.removeEventListener(OFF_LINE, handler); + }); + return { + onLineRef, + }; +} diff --git a/src/hooks/web/usePage.ts b/src/hooks/web/usePage.ts new file mode 100644 index 000000000..a56e0a552 --- /dev/null +++ b/src/hooks/web/usePage.ts @@ -0,0 +1,43 @@ +import { appStore } from '/@/store/modules/app'; +import type { RouteLocationRaw } from 'vue-router'; + +import { useRouter } from 'vue-router'; +import { PageEnum } from '/@/enums/pageEnum'; +import { isString } from '/@/utils/is'; +import { unref } from 'vue'; + +export type RouteLocationRawEx = Omit & { path: PageEnum }; + +function handleError(e: Error) { + console.error(e); + setTimeout(() => { + appStore.commitPageLoadingState(false); + }, 101); +} + +// page switch +export function useGo() { + const { push, replace } = useRouter(); + function go(opt: PageEnum | RouteLocationRawEx = PageEnum.BASE_HOME, isReplace = false) { + if (isString(opt)) { + isReplace ? replace(opt).catch(handleError) : push(opt).catch(handleError); + } else { + const o = opt as RouteLocationRaw; + isReplace ? replace(o).catch(handleError) : push(o).catch(handleError); + } + } + return go; +} + +/** + * @description: redo current page + */ +export const useRedo = () => { + const { push, currentRoute } = useRouter(); + function redo() { + push({ + path: '/redirect' + unref(currentRoute).fullPath, + }); + } + return redo; +}; diff --git a/src/hooks/web/usePermission.ts b/src/hooks/web/usePermission.ts new file mode 100644 index 000000000..db61f8c9f --- /dev/null +++ b/src/hooks/web/usePermission.ts @@ -0,0 +1,97 @@ +import { appStore } from './../../store/modules/app'; +import { permissionStore } from '/@/store/modules/permission'; +import { useTabs } from './useTabs'; +import { RoleEnum } from '/@/enums/roleEnum'; +import router, { resetRouter } from '/@/router'; +import { userStore } from '/@/store/modules/user'; +import { isArray } from '/@/utils/is'; +import { RootRoute } from '/@/router/routes'; +import type { RouteRecordRaw } from 'vue-router'; +import { PermissionModeEnum } from '/@/enums/appEnum'; +import { intersection } from 'lodash-es'; + +export function usePermission() { + /** + * 更换权限模式 + */ + async function togglePermissionMode() { + appStore.commitProjectConfigState({ + permissionMode: + appStore.getProjectConfig.permissionMode === PermissionModeEnum.BACK + ? PermissionModeEnum.ROLE + : PermissionModeEnum.BACK, + }); + resume(); + // location.reload(); + } + + async function resume(id?: string | number) { + resetRouter(); + const routes = await permissionStore.buildRoutesAction(id); + routes.forEach((route) => { + router.addRoute(RootRoute.name!, route as RouteRecordRaw); + }); + permissionStore.commitLastBuildMenuTimeState(); + const { + // closeAll, + closeOther, + } = useTabs(); + // closeAll(); + closeOther(); + } + + /** + * 角色模式下判断是否显示 + */ + function hasPermission(value?: RoleEnum | RoleEnum[] | string | string[], def = true): boolean { + const permMode = appStore.getProjectConfig.permissionMode; + if (PermissionModeEnum.ROLE === permMode) { + // !不传默认可见 + if (!value) { + return def; + } + if (!isArray(value)) { + return userStore.getRoleListState.includes(value as RoleEnum); + } + return (intersection(value, userStore.getRoleListState) as RoleEnum[]).length > 0; + } + if (PermissionModeEnum.BACK === permMode) { + // !不传默认可见 + if (!value) { + return def; + } + const allCodeList = permissionStore.getPermCodeListState; + if (!isArray(value)) { + return allCodeList.includes(value as string); + } + return (intersection(value, allCodeList) as string[]).length > 0; + } + + return true; + } + + /** + * 更新角色 + * @param roles + */ + async function changeRole(roles: RoleEnum | RoleEnum[]): Promise { + if (appStore.getProjectConfig.permissionMode !== PermissionModeEnum.ROLE) { + throw new Error('请在配置中将PermissionModeEnum切换为ROLE模式在进行操作!'); + } + if (!isArray(roles)) { + roles = [roles]; + } + userStore.commitRoleListState(roles); + await resume(); + } + + /** + * + */ + async function changeMenu(id?: string | number) { + // 这里传入id是为测试,实际可以不用传,会自动获取登录人的id + resume(id); + } + + return { changeRole, hasPermission, togglePermissionMode, changeMenu }; +} diff --git a/src/hooks/web/useScript.ts b/src/hooks/web/useScript.ts new file mode 100644 index 000000000..ce790574c --- /dev/null +++ b/src/hooks/web/useScript.ts @@ -0,0 +1,40 @@ +import { onMounted, ref } from 'vue'; + +interface ScriptOptions { + src: string; +} + +export function useScript(opts: ScriptOptions) { + const isLoading = ref(false); + const error = ref(false); + const success = ref(false); + + const promise = new Promise((resolve, reject) => { + onMounted(() => { + const script = document.createElement('script'); + script.onload = function () { + isLoading.value = false; + success.value = true; + error.value = false; + resolve(); + }; + + script.onerror = function (err) { + isLoading.value = false; + success.value = false; + error.value = true; + reject(err); + }; + + script.src = opts.src; + document.head.appendChild(script); + }); + }); + + return { + isLoading, + error, + success, + toPromise: () => promise, + }; +} diff --git a/src/hooks/web/useSessionStorage.ts b/src/hooks/web/useSessionStorage.ts new file mode 100644 index 000000000..fbfa4bd67 --- /dev/null +++ b/src/hooks/web/useSessionStorage.ts @@ -0,0 +1,5 @@ +import { createStorage } from '/@/utils/storage/index'; + +export function useSessionStorage() { + return createStorage(sessionStorage); +} diff --git a/src/hooks/web/useSideBar.ts b/src/hooks/web/useSideBar.ts new file mode 100644 index 000000000..9b1ed3ab4 --- /dev/null +++ b/src/hooks/web/useSideBar.ts @@ -0,0 +1,20 @@ +import { computed } from 'vue'; +import { appStore } from '/@/store/modules/app'; + +export function useSideBar() { + const currentCollapsedRef = computed(() => { + return appStore.getProjectConfig.menuSetting.collapsed; + }); + const changeCollapsed = (collapsed: boolean) => { + appStore.commitProjectConfigState({ + menuSetting: { + collapsed: collapsed, + }, + }); + }; + return { + openSider: changeCollapsed(false), + closeSider: changeCollapsed(true), + currentCollapsedRef, + }; +} diff --git a/src/hooks/web/useTabs.ts b/src/hooks/web/useTabs.ts new file mode 100644 index 000000000..a9d17e5ae --- /dev/null +++ b/src/hooks/web/useTabs.ts @@ -0,0 +1,70 @@ +import { TabItem, tabStore } from '/@/store/modules/tab'; +import { appStore } from '/@/store/modules/app'; +type Fn = () => void; +type RouteFn = (tabItem: TabItem) => void; + +interface TabFn { + refreshPageFn: RouteFn; + closeAllFn: Fn; + closeLeftFn: RouteFn; + closeRightFn: RouteFn; + closeOtherFn: RouteFn; + closeCurrentFn: RouteFn; +} + +let refreshPage: RouteFn; +let closeAll: Fn; +let closeLeft: RouteFn; +let closeRight: RouteFn; +let closeOther: RouteFn; +let closeCurrent: RouteFn; + +export let isInitUseTab = false; +export function useTabs() { + function initTabFn({ + refreshPageFn, + closeAllFn, + closeLeftFn, + closeRightFn, + closeOtherFn, + closeCurrentFn, + }: TabFn) { + if (isInitUseTab) return; + refreshPageFn && (refreshPage = refreshPageFn); + closeAllFn && (closeAll = closeAllFn); + closeLeftFn && (closeLeft = closeLeftFn); + closeRightFn && (closeRight = closeRightFn); + closeOtherFn && (closeOther = closeOtherFn); + closeCurrentFn && (closeCurrent = closeCurrentFn); + isInitUseTab = true; + } + + function resetCache() { + const def = undefined as any; + refreshPage = def; + closeAll = def; + closeLeft = def; + closeRight = def; + closeOther = def; + closeCurrent = def; + } + + function canIUseFn(): boolean { + const { getProjectConfig } = appStore; + const { multiTabsSetting: { show } = {} } = getProjectConfig; + if (!show) { + throw new Error('当前未开启多标签页,请在设置中打开!'); + } + return !!show; + } + return { + initTabFn, + refreshPage: () => canIUseFn() && refreshPage(tabStore.getCurrentTab), + closeAll: () => canIUseFn() && closeAll(), + closeLeft: () => canIUseFn() && closeLeft(tabStore.getCurrentTab), + closeRight: () => canIUseFn() && closeRight(tabStore.getCurrentTab), + closeOther: () => canIUseFn() && closeOther(tabStore.getCurrentTab), + closeCurrent: () => canIUseFn() && closeCurrent(tabStore.getCurrentTab), + resetCache: () => canIUseFn() && resetCache(), + }; +} diff --git a/src/hooks/web/useTitle.ts b/src/hooks/web/useTitle.ts new file mode 100644 index 000000000..cee23d412 --- /dev/null +++ b/src/hooks/web/useTitle.ts @@ -0,0 +1,32 @@ +import type { Ref } from 'vue'; + +import { ref, watch } from 'vue'; +import { tryOnUnmounted } from '/@/utils/helper/vueHelper'; +import { isString } from '/@/utils/is'; + +export function useTitle(overrideTitle: string | null = null): Ref { + const title = ref(isString(overrideTitle) ? overrideTitle : document.title); + const observer = new MutationObserver((m) => { + title.value = m[0].target.textContent; + }); + + watch( + title, + (t, o) => { + if (isString(t) && t !== o) { + document.title = t; + } + }, + { + immediate: true, + flush: 'sync', + } + ); + + const titleElement = document.querySelector('title')!; + observer.observe(titleElement, { childList: true }); + tryOnUnmounted(() => { + observer.disconnect(); + }); + return title; +} diff --git a/src/hooks/web/useVisibilityState.ts b/src/hooks/web/useVisibilityState.ts new file mode 100644 index 000000000..101ae0163 --- /dev/null +++ b/src/hooks/web/useVisibilityState.ts @@ -0,0 +1,20 @@ +import { ref, onUnmounted, computed } from '@vue/runtime-dom'; +import { isDef } from '/@/utils/is'; + +export default function useVisibilityState() { + const refVisibility = ref(true); + + if (isDef(document) && isDef(document.visibilityState)) { + const setVisibility = () => { + refVisibility.value = document.visibilityState === 'visible'; + }; + + document.addEventListener('visibilitychange', setVisibility, false); + + onUnmounted(() => { + document.removeEventListener('visibilitychange', setVisibility); + }); + } + + return computed(() => refVisibility.value); +} diff --git a/src/hooks/web/useWatermark.ts b/src/hooks/web/useWatermark.ts new file mode 100644 index 000000000..e641cf8e5 --- /dev/null +++ b/src/hooks/web/useWatermark.ts @@ -0,0 +1,62 @@ +import { getCurrentInstance, onBeforeUnmount, ref, Ref, unref } from 'vue'; + +const domSymbol = Symbol('watermark-dom'); + +export function useWatermark(appendEl: Ref = ref(document.body)) { + const id = domSymbol.toString(); + const clear = () => { + const domId = document.getElementById(id); + if (domId) { + const el = unref(appendEl); + el && el.removeChild(domId); + } + }; + const createWatermark = (str: string) => { + clear(); + + const can = document.createElement('canvas'); + can.width = 300; + can.height = 240; + + const cans = can.getContext('2d'); + if (cans) { + cans.rotate((-20 * Math.PI) / 120); + cans.font = '15px Vedana'; + cans.fillStyle = 'rgba(0, 0, 0, 0.15)'; + cans.textAlign = 'left'; + cans.textBaseline = 'middle'; + cans.fillText(str, can.width / 20, can.height); + } + + const div = document.createElement('div'); + div.id = id; + div.style.pointerEvents = 'none'; + div.style.top = '3px'; + div.style.left = '0px'; + div.style.position = 'absolute'; + div.style.zIndex = '100000'; + div.style.width = document.documentElement.clientWidth + 'px'; + div.style.height = document.documentElement.clientHeight + 'px'; + div.style.background = 'url(' + can.toDataURL('image/png') + ') left top repeat'; + const el = unref(appendEl); + el && el.appendChild(div); + return id; + }; + + function setWatermark(str: string) { + createWatermark(str); + const func = () => { + createWatermark(str); + }; + window.addEventListener('resize', func); + const instance = getCurrentInstance(); + if (instance) { + onBeforeUnmount(() => { + clear(); + window.addEventListener('resize', func); + }); + } + } + + return { setWatermark, clear }; +} diff --git a/src/layouts/Logo.vue b/src/layouts/Logo.vue new file mode 100644 index 000000000..545e7fcee --- /dev/null +++ b/src/layouts/Logo.vue @@ -0,0 +1,54 @@ + + diff --git a/src/layouts/default/LayoutBreadcrumb.tsx b/src/layouts/default/LayoutBreadcrumb.tsx new file mode 100644 index 000000000..302fb79a1 --- /dev/null +++ b/src/layouts/default/LayoutBreadcrumb.tsx @@ -0,0 +1,93 @@ +import type { AppRouteRecordRaw } from '/@/router/types'; +import type { RouteLocationMatched } from 'vue-router'; + +import { defineComponent, TransitionGroup, unref, watch, ref } from 'vue'; +import Breadcrumb from '/@/components/Breadcrumb/Breadcrumb.vue'; +import BreadcrumbItem from '/@/components/Breadcrumb/BreadcrumbItem.vue'; +import { useRouter } from 'vue-router'; +import router from '/@/router'; +import { PageEnum } from '/@/enums/pageEnum'; +import { isBoolean } from '/@/utils/is'; + +import { compile } from 'path-to-regexp'; + +export default defineComponent({ + name: 'BasicBreadcrumb', + setup() { + const itemList = ref([]); + const { currentRoute, push } = useRouter(); + + function getBreadcrumb() { + const { matched } = unref(currentRoute); + const matchedList = matched.filter((item) => item.meta && item.meta.title).slice(1); + const firstItem = matchedList[0]; + const ret = getHomeRoute(firstItem); + + if (!isBoolean(ret)) { + matchedList.unshift(ret); + } + itemList.value = ((matchedList as any) as AppRouteRecordRaw[]).filter( + (item) => item.meta && item.meta.title && !item.meta.hideBreadcrumb + ); + } + + function getHomeRoute(firstItem: RouteLocationMatched) { + if (!firstItem || !firstItem.name) return false; + const routes = router.getRoutes(); + const homeRoute = routes.find((item) => item.path === PageEnum.BASE_HOME); + if (!homeRoute) return false; + if (homeRoute.name === firstItem.name) return false; + return homeRoute; + } + function pathCompile(path: string) { + const { params } = unref(currentRoute); + const toPath = compile(path); + return toPath(params); + } + function handleItemClick(item: AppRouteRecordRaw) { + const { redirect, path, meta } = item; + if (meta.disabledRedirect) return; + if (redirect) { + push(redirect as string); + return; + } + return push(pathCompile(path)); + } + + watch( + () => currentRoute.value, + () => { + if (unref(currentRoute).name === 'Redirect') return; + getBreadcrumb(); + }, + { immediate: true } + ); + + return () => ( + <> + + {() => ( + <> + + {() => { + return unref(itemList).map((item) => { + const isLink = !!item.redirect && !item.meta.disabledRedirect; + return ( + + {() => item.meta.title} + + ); + }); + }} + + + )} + + + ); + }, +}); diff --git a/src/layouts/default/LayoutContent.tsx b/src/layouts/default/LayoutContent.tsx new file mode 100644 index 000000000..21d65df87 --- /dev/null +++ b/src/layouts/default/LayoutContent.tsx @@ -0,0 +1,25 @@ +import { defineComponent } from 'vue'; +import { Layout } from 'ant-design-vue'; +// hooks + +import { ContentEnum } from '/@/enums/appEnum'; +import { appStore } from '/@/store/modules/app'; +// import { RouterView } from 'vue-router'; +import PageLayout from '/@/layouts/page/index'; +export default defineComponent({ + name: 'DefaultLayoutContent', + setup() { + return () => { + const { getProjectConfig } = appStore; + const { contentMode } = getProjectConfig; + const wrapClass = contentMode === ContentEnum.FULL ? 'full' : 'fixed'; + return ( + + {{ + default: () => , + }} + + ); + }; + }, +}); diff --git a/src/layouts/default/LayoutHeader.tsx b/src/layouts/default/LayoutHeader.tsx new file mode 100644 index 000000000..44ced55b4 --- /dev/null +++ b/src/layouts/default/LayoutHeader.tsx @@ -0,0 +1,154 @@ +import { defineComponent, unref, computed } from 'vue'; +import { Layout, Tooltip } from 'ant-design-vue'; +import Logo from '/@/layouts/Logo.vue'; +import UserDropdown from './UserDropdown'; +import LayoutMenu from './LayoutMenu'; +import { appStore } from '/@/store/modules/app'; +import { MenuModeEnum, MenuSplitTyeEnum, MenuTypeEnum } from '/@/enums/menuEnum'; +import LayoutBreadcrumb from './LayoutBreadcrumb'; +import { + RedoOutlined, + FullscreenExitOutlined, + FullscreenOutlined, + GithubFilled, + LockOutlined, +} from '@ant-design/icons-vue'; +import { useFullscreen } from '/@/hooks/web/useFullScreen'; +import { useTabs } from '/@/hooks/web/useTabs'; +import { GITHUB_URL } from '/@/settings/siteSetting'; +import LockAction from './actions/LockActionItem'; +import { useModal } from '/@/components/Modal/index'; + +export default defineComponent({ + name: 'DefaultLayoutHeader', + setup() { + const { refreshPage } = useTabs(); + const [register, { openModal }] = useModal(); + const { toggleFullscreen, isFullscreenRef } = useFullscreen(); + const getProjectConfigRef = computed(() => { + return appStore.getProjectConfig; + }); + + function goToGithub() { + window.open(GITHUB_URL, '__blank'); + } + + const headerClass = computed(() => { + const theme = unref(getProjectConfigRef).headerSetting.theme; + return theme ? `layout-header__header--${theme}` : ''; + }); + /** + * @description: 锁定屏幕 + */ + function handleLockPage() { + openModal(true); + } + return () => { + const getProjectConfig = unref(getProjectConfigRef); + const { + // useErrorHandle, + showLogo, + headerSetting: { theme: headerTheme, showRedo, showGithub, showFullScreen }, + menuSetting: { mode, type: menuType, split: splitMenu, topMenuAlign }, + showBreadCrumb, + } = getProjectConfig; + + const isSidebarType = menuType === MenuTypeEnum.SIDEBAR; + return ( + + {() => ( + <> +
    + {showLogo && !isSidebarType && } + + {mode !== MenuModeEnum.HORIZONTAL && showBreadCrumb && !splitMenu && ( + + )} + {(mode === MenuModeEnum.HORIZONTAL || splitMenu) && ( +
    + +
    + )} +
    + +
    + {showGithub && ( + // @ts-ignore + + {{ + title: () => 'github', + default: () => ( +
    + +
    + ), + }} +
    + )} + {showGithub && ( + // @ts-ignore + + {{ + title: () => '锁定屏幕', + default: () => ( +
    + +
    + ), + }} +
    + )} + {showRedo && ( + // @ts-ignore + + {{ + title: () => '刷新', + default: () => ( +
    + +
    + ), + }} +
    + )} + {showFullScreen && ( + // @ts-ignore + + {{ + title: () => (unref(isFullscreenRef) ? '退出全屏' : '全屏'), + default: () => { + const Icon: any = !unref(isFullscreenRef) ? ( + + ) : ( + + ); + return ( +
    + +
    + ); + }, + }} +
    + )} + +
    + + + )} +
    + ); + }; + }, +}); diff --git a/src/layouts/default/LayoutMenu.tsx b/src/layouts/default/LayoutMenu.tsx new file mode 100644 index 000000000..e323751c1 --- /dev/null +++ b/src/layouts/default/LayoutMenu.tsx @@ -0,0 +1,215 @@ +import type { PropType } from 'vue'; +import type { Menu } from '/@/router/types'; + +import { computed, defineComponent, unref, ref, onMounted, watch } from 'vue'; +import { BasicMenu } from '/@/components/Menu/index'; +import Logo from '/@/layouts/Logo.vue'; + +import { PageEnum } from '/@/enums/pageEnum'; +import { MenuModeEnum, MenuSplitTyeEnum, MenuTypeEnum } from '/@/enums/menuEnum'; + +// store +import { appStore } from '/@/store/modules/app'; +import { menuStore } from '/@/store/modules/menu'; + +import { + getMenus, + getFlatMenus, + getShallowMenus, + getChildrenMenus, + getFlatChildrenMenus, + getCurrentParentPath, +} from '/@/router/menus/index'; +import { useGo } from '/@/hooks/web/usePage'; +import { useRouter } from 'vue-router'; +import { useThrottle } from '/@/hooks/core/useThrottle'; +import { permissionStore } from '/@/store/modules/permission'; + +// import +export default defineComponent({ + name: 'DefaultLayoutMenu', + props: { + theme: { + type: String as PropType, + default: '', + }, + splitType: { + type: Number as PropType, + default: MenuSplitTyeEnum.NONE, + }, + parentMenuPath: { + type: String as PropType, + default: '', + }, + showSearch: { + type: Boolean as PropType, + default: true, + }, + menuMode: { + type: [String] as PropType, + default: '', + }, + }, + setup(props) { + const menusRef = ref([]); + const flatMenusRef = ref([]); + const { currentRoute } = useRouter(); + + const getProjectConfigRef = computed(() => { + return appStore.getProjectConfig; + }); + + const getIsHorizontalRef = computed(() => { + return unref(getProjectConfigRef).menuSetting.mode === MenuModeEnum.HORIZONTAL; + }); + + const go = useGo(); + onMounted(() => { + genMenus(); + }); + const [throttleHandleSplitLeftMenu] = useThrottle(handleSplitLeftMenu, 50); + + // watch( + // () => menuStore.getCurrentTopSplitMenuPathState, + // async (parentPath: string) => { + // throttleHandleSplitLeftMenu(parentPath); + // } + // ); + watch( + [() => unref(currentRoute).path, () => props.splitType], + async ([path, splitType]: [string, MenuSplitTyeEnum]) => { + if (splitType !== MenuSplitTyeEnum.LEFT && !unref(getIsHorizontalRef)) return; + const parentPath = await getCurrentParentPath(path); + parentPath && throttleHandleSplitLeftMenu(parentPath); + }, + { + immediate: true, + } + ); + watch( + [() => permissionStore.getLastBuildMenuTimeState, permissionStore.getBackMenuListState], + () => { + genMenus(); + } + ); + + watch([() => appStore.getProjectConfig.menuSetting.split], () => { + if (props.splitType !== MenuSplitTyeEnum.LEFT && !unref(getIsHorizontalRef)) return; + genMenus(); + }); + + async function handleSplitLeftMenu(parentPath: string) { + const isSplitMenu = unref(getProjectConfigRef).menuSetting.split; + if (!isSplitMenu) return; + const { splitType } = props; + // 菜单分割模式-left + if (splitType === MenuSplitTyeEnum.LEFT) { + const children = await getChildrenMenus(parentPath); + if (!children) return; + const flatChildren = await getFlatChildrenMenus(children); + flatMenusRef.value = flatChildren; + menusRef.value = children; + } + } + + async function genMenus() { + const isSplitMenu = unref(getProjectConfigRef).menuSetting.split; + + // 普通模式 + + const { splitType } = props; + if (splitType === MenuSplitTyeEnum.NONE || !isSplitMenu) { + flatMenusRef.value = await getFlatMenus(); + menusRef.value = await getMenus(); + return; + } + + // 菜单分割模式-top + if (splitType === MenuSplitTyeEnum.TOP) { + const parentPath = await getCurrentParentPath(unref(currentRoute).path); + menuStore.commitCurrentTopSplitMenuPathState(parentPath); + const shallowMenus = await getShallowMenus(); + + flatMenusRef.value = shallowMenus; + menusRef.value = shallowMenus; + return; + } + } + + function handleMenuClick(menu: Menu) { + const { path } = menu; + if (path) { + const { splitType } = props; + // 菜单分割模式-top + if (splitType === MenuSplitTyeEnum.TOP) { + menuStore.commitCurrentTopSplitMenuPathState(path); + } + go(path as PageEnum); + } + } + + async function beforeMenuClickFn(menu: Menu) { + const { meta: { externalLink } = {} } = menu; + + if (externalLink) { + window.open(externalLink, '_blank'); + return false; + } + + return true; + } + + function handleClickSearchInput() { + if (menuStore.getCollapsedState) { + menuStore.commitCollapsedState(false); + } + } + + const showSearchRef = computed(() => { + const { showSearch, type, mode } = unref(getProjectConfigRef).menuSetting; + return ( + showSearch && + props.showSearch && + !(type === MenuTypeEnum.MIX && mode === MenuModeEnum.HORIZONTAL) + ); + }); + + return () => { + const { + showLogo, + menuSetting: { type: menuType, mode, theme, collapsed }, + } = unref(getProjectConfigRef); + + const isSidebarType = menuType === MenuTypeEnum.SIDEBAR; + const isShowLogo = showLogo && isSidebarType; + const themeData = props.theme || theme; + + return ( + + {{ + header: () => + isShowLogo && ( + + ), + }} + + ); + }; + }, +}); diff --git a/src/layouts/default/LayoutSideBar.tsx b/src/layouts/default/LayoutSideBar.tsx new file mode 100644 index 000000000..45e025eb2 --- /dev/null +++ b/src/layouts/default/LayoutSideBar.tsx @@ -0,0 +1,193 @@ +import { computed, defineComponent, nextTick, onMounted, ref, unref } from 'vue'; + +import { Layout } from 'ant-design-vue'; +import SideBarTrigger from './SideBarTrigger'; +import { menuStore } from '/@/store/modules/menu'; + +import darkMiniIMg from '/@/assets/images/sidebar/dark-mini.png'; +import lightMiniImg from '/@/assets/images/sidebar/light-mini.png'; +import darkImg from '/@/assets/images/sidebar/dark.png'; +import lightImg from '/@/assets/images/sidebar/light.png'; +import { appStore } from '/@/store/modules/app'; +import { MenuModeEnum, MenuSplitTyeEnum, MenuThemeEnum } from '/@/enums/menuEnum'; +import { useDebounce } from '/@/hooks/core/useDebounce'; +import LayoutMenu from './LayoutMenu'; +export default defineComponent({ + name: 'DefaultLayoutSideBar', + setup() { + const initRef = ref(false); + const brokenRef = ref(false); + const collapseRef = ref(true); + const dragBarRef = ref>(null); + const sideRef = ref(null); + + const getProjectConfigRef = computed(() => { + return appStore.getProjectConfig; + }); + + // 根据展开状态设置背景图片 + const getStyle = computed((): any => { + const collapse = unref(collapseRef); + + const theme = unref(getProjectConfigRef).menuSetting.theme; + let bg = ''; + if (theme === MenuThemeEnum.DARK) { + bg = collapse ? darkMiniIMg : darkImg; + } + if (theme === MenuThemeEnum.LIGHT) { + bg = collapse ? lightMiniImg : lightImg; + } + return { + 'background-image': `url(${bg})`, + }; + }); + + function onCollapseChange(val: boolean) { + if (initRef.value) { + collapseRef.value = val; + menuStore.commitCollapsedState(val); + } else { + const collapsed = appStore.getProjectConfig.menuSetting.collapsed; + !collapsed && menuStore.commitCollapsedState(val); + } + initRef.value = true; + } + + // 菜单区域拖拽 - 鼠标移动 + function handleMouseMove(ele: any, wrap: any, clientX: number) { + document.onmousemove = function (innerE) { + let iT = ele.left + ((innerE || event).clientX - clientX); + innerE = innerE || window.event; + // let tarnameb = innerE.target || innerE.srcElement; + const maxT = 600; + const minT = 80; + iT < 0 && (iT = 0); + iT > maxT && (iT = maxT); + iT < minT && (iT = minT); + ele.style.left = wrap.style.width = iT + 'px'; + return false; + }; + } + + // 菜单区域拖拽 - 鼠标松开 + function removeMouseup(ele: any) { + const wrap = unref(sideRef).$el; + document.onmouseup = function () { + document.onmousemove = null; + document.onmouseup = null; + const width = parseInt(wrap.style.width); + menuStore.commitDragStartState(false); + if (!menuStore.getCollapsedState) { + if (width > 100) { + setMenuWidth(width); + } else { + menuStore.commitCollapsedState(true); + } + } else { + if (width > 80) { + setMenuWidth(width); + menuStore.commitCollapsedState(false); + } + } + + ele.releaseCapture && ele.releaseCapture(); + }; + } + + function setMenuWidth(width: number) { + appStore.commitProjectConfigState({ + menuSetting: { + menuWidth: width, + }, + }); + } + + function changeWrapWidth() { + const ele = unref(dragBarRef) as any; + const side = unref(sideRef); + + const wrap = (side || {}).$el; + // const eleWidth = 6; + ele && + (ele.onmousedown = (e: any) => { + menuStore.commitDragStartState(true); + wrap.style.transition = 'unset'; + const clientX = (e || event).clientX; + ele.left = ele.offsetLeft; + handleMouseMove(ele, wrap, clientX); + removeMouseup(ele); + ele.setCapture && ele.setCapture(); + return false; + }); + } + function handleBreakpoint(broken: boolean) { + brokenRef.value = broken; + } + + onMounted(() => { + nextTick(() => { + const [exec] = useDebounce(changeWrapWidth, 20); + exec(); + }); + }); + + const getDragBarStyle = computed(() => { + if (menuStore.getCollapsedState) { + return { left: '80px' }; + } + return {}; + }); + + const getCollapsedWidth = computed(() => { + return unref(brokenRef) ? 0 : 80; + }); + + function renderDragLine() { + const { menuSetting: { hasDrag = true } = {} } = unref(getProjectConfigRef); + return ( +
    + ); + } + + return () => { + const { + menuSetting: { theme, split: splitMenu }, + } = unref(getProjectConfigRef); + const { getCollapsedState, getMenuWidthState } = menuStore; + + return ( + + {{ + trigger: () => , + default: () => ( + <> + + {renderDragLine()} + + ), + }} + + ); + }; + }, +}); diff --git a/src/layouts/default/SideBarTrigger.tsx b/src/layouts/default/SideBarTrigger.tsx new file mode 100644 index 000000000..c04303d96 --- /dev/null +++ b/src/layouts/default/SideBarTrigger.tsx @@ -0,0 +1,12 @@ +import { DoubleRightOutlined, DoubleLeftOutlined } from '@ant-design/icons-vue'; +import { defineComponent } from 'vue'; + +// store +import { menuStore } from '/@/store/modules/menu'; + +export default defineComponent({ + name: 'SideBarTrigger', + setup() { + return () => (menuStore.getCollapsedState ? : ); + }, +}); diff --git a/src/layouts/default/UserDropdown.tsx b/src/layouts/default/UserDropdown.tsx new file mode 100644 index 000000000..f5b3c7b0c --- /dev/null +++ b/src/layouts/default/UserDropdown.tsx @@ -0,0 +1,103 @@ +// components +import { Dropdown, Menu, Divider } from 'ant-design-vue'; + +import { defineComponent, computed, unref } from 'vue'; + +// res +import headerImg from '/@/assets/images/header.jpg'; + +import Icon from '/@/components/Icon/index'; + +import { userStore } from '/@/store/modules/user'; + +import { DOC_URL } from '/@/settings/siteSetting'; +import { appStore } from '/@/store/modules/app'; + +const prefixCls = 'user-dropdown'; +export default defineComponent({ + name: 'UserDropdown', + setup() { + const getProjectConfigRef = computed(() => { + return appStore.getProjectConfig; + }); + + /** + * @description: 退出登录 + */ + function handleLoginOut() { + userStore.confirmLoginOut(); + } + + // 打开文档 + function openDoc() { + window.open(DOC_URL, '__blank'); + } + + function handleMenuClick(e: any) { + if (e.key === 'loginOut') { + handleLoginOut(); + } + if (e.key === 'doc') { + openDoc(); + } + } + const getUserInfo = computed(() => { + const { realName = '', desc } = userStore.getUserInfoState || {}; + return { realName, desc }; + }); + return () => { + const { realName } = unref(getUserInfo); + const { + headerSetting: { showDoc }, + } = unref(getProjectConfigRef); + return ( + + {{ + default: () => ( + <> +
    + +
    +
    {realName}
    +
    +
    + + ), + overlay: () => ( + + {() => ( + <> + {showDoc && ( + + {() => ( + <> + + + 文档 + + + )} + + )} + {showDoc && } + + + {() => ( + <> + + + 退出系统 + + + )} + + + )} + + ), + }} +
    + ); + }; + }, +}); diff --git a/src/layouts/default/actions/LockActionItem.less b/src/layouts/default/actions/LockActionItem.less new file mode 100644 index 000000000..822c48682 --- /dev/null +++ b/src/layouts/default/actions/LockActionItem.less @@ -0,0 +1,31 @@ +.lock-modal { + &__entry { + position: relative; + width: 500px; + height: 240px; + padding: 80px 30px 0 30px; + background: #fff; + border-radius: 10px; + } + + &__header { + position: absolute; + top: 0; + left: calc(50% - 45px); + width: auto; + text-align: center; + + &-img { + width: 70px; + border-radius: 50%; + } + + &-name { + margin-top: 5px; + } + } + + &__footer { + text-align: center; + } +} diff --git a/src/layouts/default/actions/LockActionItem.tsx b/src/layouts/default/actions/LockActionItem.tsx new file mode 100644 index 000000000..1c0f4bbc8 --- /dev/null +++ b/src/layouts/default/actions/LockActionItem.tsx @@ -0,0 +1,82 @@ +// 组件相关 +import { defineComponent } from 'vue'; +import { BasicModal, useModalInner } from '/@/components/Modal/index'; + +// hook +import { BasicForm, useForm } from '/@/components/Form/index'; + +import headerImg from '/@/assets/images/header.jpg'; + +import { appStore } from '/@/store/modules/app'; +import { userStore } from '/@/store/modules/user'; +import Button from '/@/components/Button/index.vue'; +import './LockActionItem.less'; +const prefixCls = 'lock-modal'; +export default defineComponent({ + name: 'LockModal', + setup(_, { attrs }) { + const [register, { setModalProps }] = useModalInner(); + // 样式前缀 + const [registerForm, { validateFields, resetFields }] = useForm({ + // 隐藏按钮 + showActionButtonGroup: false, + // 表单项 + schemas: [ + { + field: 'password', + label: '锁屏密码', + component: 'InputPassword', + componentProps: { + placeholder: '请输入锁屏密码', + }, + rules: [{ required: true }], + }, + ], + }); + /** + * @description: lock + */ + async function lock(valid = true) { + let password: string | undefined = ''; + + try { + const values = (await validateFields()) as any; + password = values.password; + if (!valid) { + password = undefined; + } + setModalProps({ + visible: false, + }); + + appStore.commitLockInfoState({ + isLock: true, + pwd: password, + }); + resetFields(); + } catch (error) {} + } + // 账号密码登录 + return () => ( + + {() => ( +
    +
    + +

    {userStore.getUserInfoState.realName}

    +
    + +
    + + +
    +
    + )} +
    + ); + }, +}); diff --git a/src/layouts/default/index.less b/src/layouts/default/index.less new file mode 100644 index 000000000..b6f194207 --- /dev/null +++ b/src/layouts/default/index.less @@ -0,0 +1,401 @@ +@import (reference) '../../design/index.less'; + +.default-layout { + &__content { + position: relative; + + &.fixed { + overflow: hidden; + } + } + + &__loading { + position: absolute; + z-index: @page-loading-z-index; + } + + &__main { + position: relative; + height: 100%; + // overflow: hidden; + // overflow: auto; + + &.fixed { + overflow: auto; + } + + &.fixed.lock { + overflow: hidden; + } + } + + .layout-content { + position: relative; + // height: 100%; + + &.fixed { + width: 1200px; + margin: 0 auto; + } + } + + .layout-menu { + &__logo { + height: @header-height; + padding: 10px; + + img { + width: @logo-width; + height: @logo-width; + } + + &.light { + .logo-title { + color: @text-color-base; + } + } + + &.dark { + .logo-title { + color: @white; + } + } + } + } + + .layout-sidebar { + background-size: 100% 100%; + + .ant-layout-sider-zero-width-trigger { + top: 40%; + z-index: 10; + } + + &__dargbar { + position: absolute; + top: 0; + right: -2px; + z-index: @sider-drag-z-index; + width: 2px; + height: 100%; + cursor: col-resize; + border-top: none; + border-bottom: none; + + &.hide { + display: none; + } + + &:hover { + background: @primary-color; + box-shadow: 0 0 4px 0 rgba(28, 36, 56, 0.15); + } + } + } + + .setting-button { + top: 45%; + right: 0; + border-radius: 10px 0 0 10px; + + .svg { + width: 2em; + } + } + + &__tabs { + z-index: 10; + height: @multiple-height; + padding: 0; + line-height: @multiple-height; + background: @border-color-shallow-light; + box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.08); + } +} + +.setting-drawer { + .ant-drawer-body { + padding-top: 0; + background: @white; + } + + &__footer { + display: flex; + flex-direction: column; + align-items: center; + } + + &__cell-item { + display: flex; + justify-content: space-between; + margin: 16px 0; + } + + &__theme-item { + display: flex; + flex-wrap: wrap; + margin: 16px 0; + + span { + display: inline-block; + width: 20px; + height: 20px; + margin-top: 10px; + margin-right: 10px; + cursor: pointer; + border-radius: 4px; + + svg { + display: none; + } + + &.active { + svg { + display: inline-block; + margin-left: 4px; + font-size: 0.8em; + fill: @white; + } + } + } + } + + &__siderbar { + display: flex; + + > div { + position: relative; + + .check-icon { + position: absolute; + top: 40%; + left: 40%; + display: none; + color: @primary-color; + + &.active { + display: inline-block; + } + } + } + + img { + margin-right: 10px; + cursor: pointer; + } + } +} + +.layout-header { + display: flex; + height: @header-height; + padding: 0 20px 0 0; + color: @white; + align-items: center; + justify-content: space-between; + + &__header--light { + background: @white; + border-bottom: 1px solid @header-light-bottom-border-color; + + .layout-header__menu { + height: calc(@header-height - 1px); + + .ant-menu-submenu { + height: @header-height; + line-height: @header-height; + } + } + + .layout-header__logo { + height: @header-height; + color: @text-color-base; + + img { + width: @logo-width; + height: @logo-width; + margin-right: 6px; + } + + &:hover { + background: @header-light-bg-hover-color; + } + } + + .layout-header__action { + &-item { + &:hover { + background: @header-light-bg-hover-color; + } + } + + &-icon { + color: @text-color-base; + } + } + + .layout-header__user-dropdown { + &:hover { + background: @header-light-bg-hover-color; + } + } + + .user-dropdown { + &__name { + color: @text-color-base; + } + + &__desc { + color: @header-light-desc-color; + } + } + } + + &__header--dark { + background: @header-dark-bg-color; + + .layout-header__action { + &-item { + &:hover { + background: @header-dark-bg-hover-color; + } + } + } + + .layout-header__logo { + height: @header-height; + + img { + width: @logo-width; + height: @logo-width; + margin-right: 10px; + } + + &:hover { + background: @header-dark-bg-hover-color; + } + } + + .layout-header__user-dropdown { + &:hover { + background: @header-dark-bg-hover-color; + } + } + + .breadcrumb { + &__item:last-child .breadcrumb__inner, + &__item:last-child &__inner a, + &__item:last-child &__inner a:hover, + &__item:last-child &__inner:hover { + font-weight: 400; + color: rgba(255, 255, 255, 0.6); + cursor: text; + } + + &__inner, + &__separator { + color: @white; + } + } + } + + &-lm { + display: flex; + } + + &__logo { + padding: 0 10px; + } + + &__bread { + flex: 1; + display: none; + } + + &__action { + display: flex; + align-items: center; + + &-item { + display: flex; + align-items: center; + height: @header-height; + font-size: 1.3em; + cursor: pointer; + } + + &-icon { + padding: 0 12px; + } + } + + &__menu { + display: flex; + margin-left: 20px; + overflow: hidden; + align-items: center; + } + + &__user-dropdown { + height: 52px; + padding: 0 0 0 10px; + } +} + +.user-dropdown { + display: flex; + height: 100%; + cursor: pointer; + align-items: center; + + img { + width: @logo-width; + height: @logo-width; + margin-right: 24px; + } + + &__header { + border-radius: 50%; + } + + &__divider { + width: 1px; + height: 30px; + margin-right: 20px; + background: #c6d9ee; + } + + &__exit { + margin-top: -40px; + font-size: 12px; + color: #c6d9ee; + text-align: center; + + > section { + height: 20px; + } + } + + &__info { + display: flex; + margin-right: 12px; + flex-direction: column; + + > section { + line-height: 1.8; + } + } + + &__name { + font-size: 14px; + } + + &__desc { + font-size: 12px; + .text-truncate(); + } +} + +.layout-breadcrumb { + padding: 0 16px; +} diff --git a/src/layouts/default/index.tsx b/src/layouts/default/index.tsx new file mode 100644 index 000000000..7c2674a67 --- /dev/null +++ b/src/layouts/default/index.tsx @@ -0,0 +1,126 @@ +import { defineComponent, unref, onMounted, computed } from 'vue'; +import { Layout, BackTop } from 'ant-design-vue'; +import LayoutHeader from './LayoutHeader'; + +import { appStore } from '/@/store/modules/app'; +import LayoutContent from './LayoutContent'; +import LayoutSideBar from './LayoutSideBar'; +import SettingBtn from './setting/index.vue'; +import MultipleTabs from './multitabs/index'; +import { FullLoading } from '/@/components/Loading/index'; + +import { MenuModeEnum, MenuTypeEnum } from '/@/enums/menuEnum'; +import { useFullContent } from '/@/hooks/web/useFullContent'; + +import LockPage from '/@/views/sys/lock/index.vue'; + +import './index.less'; +import { userStore } from '/@/store/modules/user'; +export default defineComponent({ + name: 'DefaultLayout', + setup() { + // 获取项目配置 + const { getFullContent } = useFullContent(); + + const getProjectConfigRef = computed(() => { + return appStore.getProjectConfig; + }); + const getLockMainScrollStateRef = computed(() => { + return appStore.getLockMainScrollState; + }); + + const showHeaderRef = computed(() => { + const { + headerSetting: { show }, + } = unref(getProjectConfigRef); + return show; + }); + const isShowMixHeaderRef = computed(() => { + const { + menuSetting: { type }, + } = unref(getProjectConfigRef); + return type !== MenuTypeEnum.SIDEBAR && unref(showHeaderRef); + }); + + const showSideBarRef = computed(() => { + const { + menuSetting: { show, mode }, + } = unref(getProjectConfigRef); + return show && mode !== MenuModeEnum.HORIZONTAL && !unref(getFullContent); + }); + + // const { currentRoute } = useRouter(); + onMounted(() => { + // Each refresh will request the latest user information, if you don’t need it, you can delete it + userStore.getUserInfoAction({ userId: userStore.getUserInfoState.userId }); + }); + + // Get project configuration + // const { getFullContent } = useFullContent(currentRoute); + function getTarget(): any { + const { + headerSetting: { fixed }, + } = unref(getProjectConfigRef); + return document.querySelector(`.default-layout__${fixed ? 'main' : 'content'}`); + } + return () => { + const { getPageLoading, getLockInfo } = appStore; + const { + openPageLoading, + useOpenBackTop, + showSettingButton, + multiTabsSetting: { show: showTabs }, + headerSetting: { fixed }, + } = unref(getProjectConfigRef); + // const fixedHeaderCls = fixed ? ('fixed' + getLockMainScrollState ? ' lock' : '') : ''; + const fixedHeaderCls = fixed + ? 'fixed' + (unref(getLockMainScrollStateRef) ? ' lock' : '') + : ''; + const { isLock } = getLockInfo; + return ( + + {() => ( + <> + {isLock && } + {!unref(getFullContent) && unref(isShowMixHeaderRef) && unref(showHeaderRef) && ( + + )} + {showSettingButton && } + + {() => ( + <> + {unref(showSideBarRef) && } + + {() => ( + <> + {!unref(getFullContent) && + !unref(isShowMixHeaderRef) && + unref(showHeaderRef) && } + + {showTabs && !unref(getFullContent) && ( + + {() => } + + )} + {useOpenBackTop && } +
    + {openPageLoading && ( + + )} + +
    + + )} +
    + + )} +
    + + )} +
    + ); + }; + }, +}); diff --git a/src/layouts/default/multitabs/TabContent.tsx b/src/layouts/default/multitabs/TabContent.tsx new file mode 100644 index 000000000..e3020932a --- /dev/null +++ b/src/layouts/default/multitabs/TabContent.tsx @@ -0,0 +1,108 @@ +import { TabItem, tabStore } from '/@/store/modules/tab'; +import type { PropType } from 'vue'; +import { getScaleAction, TabContentProps } from './tab.data'; + +import { defineComponent, unref, computed } from 'vue'; +import { Dropdown } from '/@/components/Dropdown/index'; +import Icon from '/@/components/Icon/index'; +import { DoubleRightOutlined } from '@ant-design/icons-vue'; +import { appStore } from '/@/store/modules/app'; + +import { TabContentEnum } from './tab.data'; +import { useTabDropdown } from './useTabDropdown'; + +export default defineComponent({ + name: 'TabContent', + props: { + tabItem: { + type: Object as PropType, + default: null, + }, + type: { + type: Number as PropType, + default: TabContentEnum.TAB_TYPE, + }, + trigger: { + type: Array as PropType, + default: () => { + return ['contextmenu']; + }, + }, + }, + setup(props) { + const getProjectConfigRef = computed(() => { + return appStore.getProjectConfig; + }); + + const getIsScaleRef = computed(() => { + const { + menuSetting: { show: showMenu }, + headerSetting: { show: showHeader }, + } = unref(getProjectConfigRef); + return !showMenu && !showHeader; + }); + + function handleContextMenu(e: Event) { + if (!props.tabItem) return; + const tableItem = props.tabItem; + e.preventDefault(); + const index = unref(tabStore.getTabsState).findIndex((tab) => tab.path === tableItem.path); + + tabStore.commitCurrentContextMenuIndexState(index); + tabStore.commitCurrentContextMenuState(props.tabItem); + } + + /** + * @description: 渲染图标 + */ + function renderIcon() { + const { tabItem } = props; + if (!tabItem) return; + const icon = tabItem.meta && tabItem.meta.icon; + if (!icon || !unref(getProjectConfigRef).multiTabsSetting.showIcon) return null; + return ; + } + function renderTabContent() { + const { tabItem: { meta } = {} } = props; + return ( +
    + {renderIcon()} + {meta && meta.title} +
    + ); + } + function renderExtraContent() { + return ( + + + + ); + } + + const { getDropMenuList, handleMenuEvent } = useTabDropdown(props as TabContentProps); + + return () => { + const { trigger, type } = props; + const { + multiTabsSetting: { showQuick }, + } = unref(getProjectConfigRef); + + const isTab = !showQuick ? true : type === TabContentEnum.TAB_TYPE; + const scaleAction = getScaleAction( + unref(getIsScaleRef) ? '缩小' : '放大', + unref(getIsScaleRef) + ); + const dropMenuList = unref(getDropMenuList) || []; + + return ( + + {() => (isTab ? renderTabContent() : renderExtraContent())} + + ); + }; + }, +}); diff --git a/src/layouts/default/multitabs/index.less b/src/layouts/default/multitabs/index.less new file mode 100644 index 000000000..80613af54 --- /dev/null +++ b/src/layouts/default/multitabs/index.less @@ -0,0 +1,135 @@ +@import (reference) '../../../design/index.less'; + +.multiple-tabs { + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); + + .ant-tabs-small { + height: @multiple-height; + } + + .ant-tabs.ant-tabs-card { + .ant-tabs-card-bar { + height: @multiple-height; + margin: 0; + background: @white; + border: 0; + box-shadow: 0 4px 26px 1px rgba(0, 0, 0, 0.08); + + .ant-tabs-nav-container { + height: @multiple-height; + padding-top: 2px; + } + + .ant-tabs-tab { + height: calc(@multiple-height - 2px); + font-size: 14px; + line-height: calc(@multiple-height - 2px); + color: @text-color-call-out; + background: @white; + border: 1px solid @border-color-shallow-dark; + border-radius: 4px 4px 0 0; + transition: none; + + .ant-tabs-close-x { + color: inherit; + } + + svg { + fill: @text-color-base; + } + + &::before { + position: absolute; + top: -2px; + right: 0; + left: 0; + height: 4px; + background-color: @primary-color; + border-radius: 16px 6px 0 0; + content: ''; + transform: scaleX(0); + transform-origin: bottom right; + } + + &:hover::before { + transform: scaleX(1); + transition: transform 0.4s ease; + transform-origin: bottom left; + } + } + + .ant-tabs-tab-active { + height: calc(@multiple-height - 3px); + color: @white; + // background: @primary-color; + background: linear-gradient( + 118deg, + rgba(@primary-color, 0.8), + rgba(@primary-color, 1) + ) !important; + border: 0; + box-shadow: 0 0 6px 1px rgba(@primary-color, 0.4); + + &::before { + display: none; + } + + svg { + fill: @white; + } + } + } + + .ant-tabs-nav > div:nth-child(1) { + padding: 0 10px; + } + + .ant-tabs-tab-prev, + .ant-tabs-tab-next { + color: @border-color-dark; + background: @white; + } + } + + .ant-tabs-tab:not(.ant-tabs-tab-active) { + .anticon-close { + font-size: 12px; + + svg { + width: 0.8em; + } + } + + &:hover { + .anticon-close { + color: @white; + } + } + } +} + +.multiple-tabs-content { + &__extra { + display: inline-block; + width: @multiple-height; + height: @multiple-height; + line-height: @multiple-height; + color: @primary-color; + text-align: center; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); + + span[role='img'] { + transform: rotate(90deg); + } + } + + &__content { + display: inline-block; + width: 100%; + padding-left: 10px; + margin-left: -10px; + cursor: pointer; + user-select: none; + } +} diff --git a/src/layouts/default/multitabs/index.tsx b/src/layouts/default/multitabs/index.tsx new file mode 100644 index 000000000..0bf394e36 --- /dev/null +++ b/src/layouts/default/multitabs/index.tsx @@ -0,0 +1,131 @@ +import type { TabContentProps } from './tab.data'; +import type { TabItem } from '/@/store/modules/tab'; +import type { AppRouteRecordRaw } from '/@/router/types'; + +import { defineComponent, watch, computed, ref, unref } from 'vue'; +import { Tabs } from 'ant-design-vue'; +import TabContent from './TabContent'; + +import { useGo } from '/@/hooks/web/usePage'; + +import { TabContentEnum } from './tab.data'; + +import { useRouter } from 'vue-router'; + +import './index.less'; +import { tabStore } from '/@/store/modules/tab'; +import { closeTab } from './useTabDropdown'; +import router from '/@/router'; +export default defineComponent({ + name: 'MultiTabs', + setup() { + let isAddAffix = false; + const go = useGo(); + const { currentRoute } = useRouter(); + // 当前激活tab + const activeKeyRef = ref(''); + // 当前tab列表 + const getTabsState = computed(() => { + return tabStore.getTabsState; + }); + + watch( + () => unref(currentRoute).path, + (path) => { + if (!isAddAffix) { + addAffixTabs(); + isAddAffix = true; + } + activeKeyRef.value = path; + + tabStore.commitAddTab((unref(currentRoute) as unknown) as AppRouteRecordRaw); + }, + { + immediate: true, + } + ); + /** + * @description: 过滤所有固定路由 + */ + function filterAffixTabs(routes: AppRouteRecordRaw[]) { + const tabs: TabItem[] = []; + routes && + routes.forEach((route) => { + if (route.meta && route.meta.affix) { + tabs.push({ + path: route.path, + name: route.name, + meta: { ...route.meta }, + }); + } + }); + return tabs; + } + /** + * @description: 设置固定tabs + */ + function addAffixTabs(): void { + const affixTabs = filterAffixTabs((router.getRoutes() as unknown) as AppRouteRecordRaw[]); + for (const tab of affixTabs) { + tabStore.commitAddTab(tab); + } + } + + // tab切换 + function handleChange(activeKey: any) { + activeKeyRef.value = activeKey; + go(activeKey, false); + } + // 关闭当前ab + function handleEdit(targetKey: string) { + // 新增操作隐藏,目前只使用删除操作 + const index = unref(getTabsState).findIndex((item) => item.path === targetKey); + index !== -1 && closeTab(unref(getTabsState)[index]); + } + + function renderQuick() { + const tabContentProps: TabContentProps = { + tabItem: (currentRoute as unknown) as AppRouteRecordRaw, + type: TabContentEnum.EXTRA_TYPE, + trigger: ['click', 'contextmenu'], + }; + return ( + + + + ); + } + function renderTabs() { + return unref(getTabsState).map((item: TabItem) => { + return ( + + {{ + tab: () => , + }} + + ); + }); + } + + return () => { + return ( +
    + + {{ + default: () => renderTabs(), + tabBarExtraContent: () => renderQuick(), + }} + +
    + ); + }; + }, +}); diff --git a/src/layouts/default/multitabs/tab.data.ts b/src/layouts/default/multitabs/tab.data.ts new file mode 100644 index 000000000..8bd878281 --- /dev/null +++ b/src/layouts/default/multitabs/tab.data.ts @@ -0,0 +1,84 @@ +import { DropMenu } from '/@/components/Dropdown/index'; +import { AppRouteRecordRaw } from '/@/router/types'; +import type { TabItem } from '/@/store/modules/tab'; + +export enum TabContentEnum { + TAB_TYPE, + EXTRA_TYPE, +} +export interface TabContentProps { + tabItem: TabItem | AppRouteRecordRaw; + type?: TabContentEnum; + trigger?: Array<'click' | 'hover' | 'contextmenu'>; +} +/** + * @description: 右键:下拉菜单文字 + */ +export enum MenuEventEnum { + // 刷新 + REFRESH_PAGE, + // 关闭当前 + CLOSE_CURRENT, + // 关闭左侧 + CLOSE_LEFT, + // 关闭右侧 + CLOSE_RIGHT, + // 关闭其他 + CLOSE_OTHER, + // 关闭所有 + CLOSE_ALL, + // 放大 + SCALE, +} + +export function getActions() { + const REFRESH_PAGE: DropMenu = { + icon: 'ant-design:reload-outlined', + event: MenuEventEnum.REFRESH_PAGE, + text: '刷新', + disabled: false, + }; + const CLOSE_CURRENT: DropMenu = { + icon: 'ant-design:close-outlined', + event: MenuEventEnum.CLOSE_CURRENT, + text: '关闭', + disabled: false, + divider: true, + }; + const CLOSE_LEFT: DropMenu = { + icon: 'ant-design:pic-left-outlined', + event: MenuEventEnum.CLOSE_LEFT, + text: '关闭左侧', + disabled: false, + divider: false, + }; + const CLOSE_RIGHT: DropMenu = { + icon: 'ant-design:pic-right-outlined', + event: MenuEventEnum.CLOSE_RIGHT, + text: '关闭右侧', + disabled: false, + divider: true, + }; + const CLOSE_OTHER: DropMenu = { + icon: 'ant-design:pic-center-outlined', + event: MenuEventEnum.CLOSE_OTHER, + text: '关闭其他', + disabled: false, + }; + const CLOSE_ALL: DropMenu = { + icon: 'ant-design:line-outlined', + event: MenuEventEnum.CLOSE_ALL, + text: '关闭全部', + disabled: false, + }; + return [REFRESH_PAGE, CLOSE_CURRENT, CLOSE_LEFT, CLOSE_RIGHT, CLOSE_OTHER, CLOSE_ALL]; +} + +export function getScaleAction(text: string, isZoom = false) { + return { + icon: isZoom ? 'codicon:screen-normal' : 'codicon:screen-full', + event: MenuEventEnum.SCALE, + text: text, + disabled: false, + }; +} diff --git a/src/layouts/default/multitabs/useTabDropdown.ts b/src/layouts/default/multitabs/useTabDropdown.ts new file mode 100644 index 000000000..6fb996e07 --- /dev/null +++ b/src/layouts/default/multitabs/useTabDropdown.ts @@ -0,0 +1,227 @@ +import type { AppRouteRecordRaw } from '/@/router/types'; +import type { TabContentProps } from './tab.data'; +import type { Ref } from 'vue'; +import type { TabItem } from '/@/store/modules/tab'; +import type { DropMenu } from '/@/components/Dropdown'; + +import { computed, unref } from 'vue'; +import { TabContentEnum, MenuEventEnum, getActions } from './tab.data'; +import { tabStore } from '/@/store/modules/tab'; +import { appStore } from '/@/store/modules/app'; +import { PageEnum } from '/@/enums/pageEnum'; +import { useGo, useRedo } from '/@/hooks/web/usePage'; +import router from '/@/router'; +import { useTabs, isInitUseTab } from '/@/hooks/web/useTabs'; + +const { initTabFn } = useTabs(); +/** + * @description: 右键下拉 + */ +export function useTabDropdown(tabContentProps: TabContentProps) { + const { currentRoute } = router; + const redo = useRedo(); + const go = useGo(); + + const isTabsRef = computed(() => tabContentProps.type === TabContentEnum.TAB_TYPE); + const getCurrentTab: Ref = computed(() => { + return unref(isTabsRef) + ? tabContentProps.tabItem + : ((unref(currentRoute) as any) as AppRouteRecordRaw); + }); + + // 当前tab列表 + const getTabsState = computed(() => { + return tabStore.getTabsState; + }); + + /** + * @description: 下拉列表 + */ + const getDropMenuList = computed(() => { + const dropMenuList = getActions(); + // 重置为初始状态 + for (const item of dropMenuList) { + item.disabled = false; + } + + // 没有tab + if (!unref(getTabsState) || unref(getTabsState).length <= 0) { + return dropMenuList; + } else if (unref(getTabsState).length === 1) { + // 只有一个tab + for (const item of dropMenuList) { + if (item.event !== MenuEventEnum.REFRESH_PAGE) { + item.disabled = true; + } + } + return dropMenuList; + } + if (!unref(getCurrentTab)) { + return; + } + const { meta, path } = unref(getCurrentTab); + // console.log(unref(getCurrentTab)); + + // 刷新按钮 + const curItem = tabStore.getCurrentContextMenuState; + const index = tabStore.getCurrentContextMenuIndexState; + const refreshDisabled = curItem ? curItem.path !== path : true; + // 关闭左侧 + const closeLeftDisabled = index === 0; + + // 关闭右侧 + const closeRightDisabled = index === unref(getTabsState).length - 1; + // 当前为固定tab + dropMenuList[0].disabled = unref(isTabsRef) ? refreshDisabled : false; + if (meta && meta.affix) { + dropMenuList[1].disabled = true; + } + dropMenuList[2].disabled = closeLeftDisabled; + dropMenuList[3].disabled = closeRightDisabled; + + return dropMenuList; + }); + + /** + * @description: 关闭所有页面时,跳转页面 + */ + function gotoPage() { + const len = unref(getTabsState).length; + const { path } = unref(currentRoute); + + let toPath: PageEnum | string = PageEnum.BASE_HOME; + + if (len > 0) { + toPath = unref(getTabsState)[len - 1].path; + } + // 跳到当前页面报错 + path !== toPath && go(toPath as PageEnum, true); + } + + function isGotoPage(currentTab?: TabItem) { + const { path } = unref(currentRoute); + const currentPath = (currentTab || unref(getCurrentTab)).path; + // 不是当前tab,关闭左侧/右侧时,需跳转页面 + if (path !== currentPath) { + go(currentPath as PageEnum, true); + } + } + function refreshPage(tabItem?: TabItem) { + try { + tabStore.commitCloseTabKeepAlive(tabItem || unref(getCurrentTab)); + } catch (error) {} + redo(); + } + function closeAll() { + tabStore.commitCloseAllTab(); + gotoPage(); + } + function closeLeft(tabItem?: TabItem) { + tabStore.closeLeftTabAction(tabItem || unref(getCurrentTab)); + isGotoPage(tabItem); + } + function closeRight(tabItem?: TabItem) { + tabStore.closeRightTabAction(tabItem || unref(getCurrentTab)); + isGotoPage(tabItem); + } + function closeOther(tabItem?: TabItem) { + tabStore.closeOtherTabAction(tabItem || unref(getCurrentTab)); + isGotoPage(tabItem); + } + function closeCurrent(tabItem?: TabItem) { + closeTab(unref(tabItem || unref(getCurrentTab))); + } + function scaleScreen() { + const { + headerSetting: { show: showHeader }, + menuSetting: { show: showMenu }, + } = appStore.getProjectConfig; + const isScale = !showHeader && !showMenu; + appStore.commitProjectConfigState({ + headerSetting: { show: isScale }, + menuSetting: { show: isScale }, + }); + } + + if (!isInitUseTab) { + initTabFn({ + refreshPageFn: refreshPage, + closeAllFn: closeAll, + closeCurrentFn: closeCurrent, + closeLeftFn: closeLeft, + closeOtherFn: closeOther, + closeRightFn: closeRight, + }); + } + + // 处理右键事件 + function handleMenuEvent(menu: DropMenu): void { + const { event } = menu; + + switch (event) { + case MenuEventEnum.SCALE: + scaleScreen(); + break; + case MenuEventEnum.REFRESH_PAGE: + // 刷新页面 + refreshPage(); + break; + // 关闭当前 + case MenuEventEnum.CLOSE_CURRENT: + closeCurrent(); + break; + // 关闭左侧 + case MenuEventEnum.CLOSE_LEFT: + closeLeft(); + break; + // 关闭右侧 + case MenuEventEnum.CLOSE_RIGHT: + closeRight(); + break; + // 关闭其他 + case MenuEventEnum.CLOSE_OTHER: + closeOther(); + break; + // 关闭其他 + case MenuEventEnum.CLOSE_ALL: + closeAll(); + break; + default: + break; + } + } + return { getDropMenuList, handleMenuEvent }; +} +export function closeTab(closedTab: TabItem) { + const { currentRoute, replace } = router; + // 当前tab列表 + const getTabsState = computed(() => { + return tabStore.getTabsState; + }); + const { path } = unref(currentRoute); + if (path !== closedTab.path) { + // 关闭的不是激活tab + tabStore.commitCloseTab(closedTab); + return; + } + // 关闭的为激活atb + let toPath: PageEnum | string; + const index = unref(getTabsState).findIndex((item) => item.path === path); + + // 如果当前为最左边tab + if (index === 0) { + // 只有一个tab,则跳转至首页,否则跳转至右tab + if (unref(getTabsState).length === 1) { + toPath = PageEnum.BASE_HOME; + } else { + // 跳转至右边tab + toPath = unref(getTabsState)[index + 1].path; + } + } else { + // 跳转至左边tab + toPath = unref(getTabsState)[index - 1].path; + } + const route = (unref(currentRoute) as unknown) as AppRouteRecordRaw; + tabStore.commitCloseTab(route); + replace(toPath); +} diff --git a/src/layouts/default/setting/SettingDrawer.tsx b/src/layouts/default/setting/SettingDrawer.tsx new file mode 100644 index 000000000..aa1300330 --- /dev/null +++ b/src/layouts/default/setting/SettingDrawer.tsx @@ -0,0 +1,670 @@ +import { defineComponent, computed, unref, ref } from 'vue'; +import { BasicDrawer } from '/@/components/Drawer/index'; +import { Divider, Switch, Tooltip, InputNumber, Select } from 'ant-design-vue'; +import Button from '/@/components/Button/index.vue'; +import { MenuModeEnum, MenuTypeEnum, MenuThemeEnum, TopMenuAlignEnum } from '/@/enums/menuEnum'; +import { ContentEnum, RouterTransitionEnum } from '/@/enums/appEnum'; +import { CopyOutlined, RedoOutlined, CheckOutlined } from '@ant-design/icons-vue'; +import { appStore } from '/@/store/modules/app'; +import { userStore } from '/@/store/modules/user'; +import { ProjectConfig } from '/@/types/config'; + +import { useMessage } from '/@/hooks/web/useMessage'; +import { useCopyToClipboard } from '/@/hooks/web/useCopyToClipboard'; + +import defaultSetting from '/@/settings/projectSetting'; + +import mixImg from '/@/assets/images/layout/menu-mix.svg'; +import sidebarImg from '/@/assets/images/layout/menu-sidebar.svg'; +import menuTopImg from '/@/assets/images/layout/menu-top.svg'; +import { updateColorWeak, updateGrayMode } from '/@/setup/theme'; + +const themeOptions = [ + { + value: MenuThemeEnum.LIGHT, + label: '亮色', + key: MenuThemeEnum.LIGHT, + }, + { + value: MenuThemeEnum.DARK, + label: '暗色', + key: MenuThemeEnum.DARK, + }, +]; +const contentModeOptions = [ + { + value: ContentEnum.FULL, + label: '流式', + key: ContentEnum.FULL, + }, + { + value: ContentEnum.FIXED, + label: '定宽', + key: ContentEnum.FIXED, + }, +]; +const topMenuAlignOptions = [ + { + value: TopMenuAlignEnum.CENTER, + label: '居中', + key: TopMenuAlignEnum.CENTER, + }, + { + value: TopMenuAlignEnum.START, + label: '居左', + key: TopMenuAlignEnum.START, + }, + { + value: TopMenuAlignEnum.END, + label: '居右', + key: TopMenuAlignEnum.END, + }, +]; + +const routerTransitionOptions = [ + RouterTransitionEnum.ZOOM_FADE, + RouterTransitionEnum.FADE, + RouterTransitionEnum.ZOOM_OUT, + RouterTransitionEnum.SIDE_FADE, + RouterTransitionEnum.FADE_BOTTOM, +].map((item) => { + return { + label: item, + value: item, + key: item, + }; +}); + +interface SwitchOptions { + config?: DeepPartial; + def?: any; + disabled?: boolean; + handler?: Fn; +} + +interface SelectConfig { + options?: SelectOptions; + def?: any; + disabled?: boolean; + handler?: Fn; +} + +export default defineComponent({ + name: 'SettingDrawer', + setup(_, { attrs }) { + const { createSuccessModal, createMessage } = useMessage(); + + const getProjectConfigRef = computed(() => { + return appStore.getProjectConfig; + }); + + const getIsHorizontalRef = computed(() => { + return unref(getProjectConfigRef).menuSetting.mode === MenuModeEnum.HORIZONTAL; + }); + + const getShowHeaderRef = computed(() => { + return unref(getProjectConfigRef).headerSetting.show; + }); + + const getShowMenuRef = computed(() => { + return unref(getProjectConfigRef).menuSetting.show && !unref(getIsHorizontalRef); + }); + + const getShowTabsRef = computed(() => { + return unref(getProjectConfigRef).multiTabsSetting.show; + }); + + function handleCopy() { + const { isSuccessRef } = useCopyToClipboard( + JSON.stringify(unref(getProjectConfigRef), null, 2) + ); + unref(isSuccessRef) && + createSuccessModal({ + title: '操作成功', + content: '复制成功,请到 src/settings/projectSetting.ts 中修改配置!', + }); + } + + function renderSidebar() { + const { + headerSetting: { theme: headerTheme }, + menuSetting: { type, theme: menuTheme, split }, + } = unref(getProjectConfigRef); + + const typeList = ref([ + { + title: '左侧菜单模式', + mode: MenuModeEnum.INLINE, + type: MenuTypeEnum.SIDEBAR, + src: sidebarImg, + }, + { + title: '混合模式', + mode: MenuModeEnum.INLINE, + type: MenuTypeEnum.MIX, + src: mixImg, + }, + + { + title: '顶部菜单模式', + mode: MenuModeEnum.HORIZONTAL, + type: MenuTypeEnum.TOP_MENU, + src: menuTopImg, + }, + ]); + return [ +
    + {unref(typeList).map((item) => { + const { title, type: ItemType, mode, src } = item; + return ( + + {{ + default: () => ( +
    + + +
    + ), + }} +
    + ); + })} +
    , + renderSwitchItem('分割菜单', { + handler: (e) => { + baseHandler('splitMenu', e); + }, + def: split, + disabled: !unref(getShowMenuRef), + }), + renderSelectItem('顶栏主题', { + handler: (e) => { + baseHandler('headerMenu', e); + }, + def: headerTheme, + options: themeOptions, + disabled: !unref(getShowHeaderRef), + }), + renderSelectItem('菜单主题', { + handler: (e) => { + baseHandler('menuTheme', e); + }, + def: menuTheme, + options: themeOptions, + disabled: !unref(getShowMenuRef), + }), + ]; + } + /** + * @description: + */ + function renderFeatures() { + const { + contentMode, + headerSetting: { fixed }, + menuSetting: { hasDrag, collapsed, showSearch, menuWidth, topMenuAlign } = {}, + } = appStore.getProjectConfig; + return [ + renderSwitchItem('侧边菜单拖拽', { + handler: (e) => { + baseHandler('hasDrag', e); + }, + def: hasDrag, + disabled: !unref(getShowMenuRef), + }), + renderSwitchItem('侧边菜单搜索', { + handler: (e) => { + baseHandler('showSearch', e); + }, + def: showSearch, + disabled: !unref(getShowMenuRef), + }), + renderSwitchItem('折叠菜单', { + handler: (e) => { + baseHandler('collapsed', e); + }, + def: collapsed, + disabled: !unref(getShowMenuRef), + }), + + renderSwitchItem('固定header', { + handler: (e) => { + baseHandler('headerFixed', e); + }, + def: fixed, + disabled: !unref(getShowHeaderRef), + }), + renderSelectItem('顶部菜单布局', { + handler: (e) => { + baseHandler('topMenuAlign', e); + }, + def: topMenuAlign, + options: topMenuAlignOptions, + disabled: !unref(getShowHeaderRef), + }), + renderSelectItem('内容区域宽度', { + handler: (e) => { + baseHandler('contentMode', e); + }, + def: contentMode, + options: contentModeOptions, + }), +
    + 自动锁屏 + { + baseHandler('menuWidth', e); + }} + defaultValue={appStore.getProjectConfig.lockTime} + formatter={(value: string) => { + if (parseInt(value) === 0) { + return '0(不自动锁屏)'; + } + return `${value}分钟`; + }} + /> +
    , +
    + 菜单展开宽度 + `${parseInt(value)}px`} + onChange={(e) => { + baseHandler('menuWidth', e); + }} + /> +
    , + ]; + } + function renderTransition() { + const { routerTransition, openRouterTransition, openPageLoading } = appStore.getProjectConfig; + + return ( + <> + {renderSwitchItem('页面切换loading', { + handler: (e) => { + baseHandler('openPageLoading', e); + }, + def: openPageLoading, + })} + {renderSwitchItem('切换动画', { + handler: (e) => { + baseHandler('openRouterTransition', e); + }, + def: openRouterTransition, + })} + {renderSelectItem('路由动画', { + handler: (e) => { + baseHandler('routerTransition', e); + }, + def: routerTransition, + options: routerTransitionOptions, + disabled: !openRouterTransition, + })} + + ); + } + function renderContent() { + const { + grayMode, + colorWeak, + fullContent, + showLogo, + headerSetting: { show: showHeader }, + menuSetting: { show: showMenu }, + multiTabsSetting: { show: showMultiple, showQuick, showIcon: showTabIcon }, + showBreadCrumb, + } = unref(getProjectConfigRef); + return [ + renderSwitchItem('面包屑', { + handler: (e) => { + baseHandler('showBreadCrumb', e); + }, + def: showBreadCrumb, + disabled: !unref(getShowHeaderRef), + }), + renderSwitchItem('标签页', { + handler: (e) => { + baseHandler('showMultiple', e); + }, + def: showMultiple, + }), + renderSwitchItem('标签页快捷按钮', { + handler: (e) => { + baseHandler('showQuick', e); + }, + def: showQuick, + disabled: !unref(getShowTabsRef), + }), + renderSwitchItem('标签页图标', { + handler: (e) => { + baseHandler('showTabIcon', e); + }, + def: showTabIcon, + disabled: !unref(getShowTabsRef), + }), + renderSwitchItem('左侧菜单', { + handler: (e) => { + baseHandler('showSidebar', e); + }, + def: showMenu, + disabled: unref(getIsHorizontalRef), + }), + renderSwitchItem('顶栏', { + handler: (e) => { + baseHandler('showHeader', e); + }, + def: showHeader, + }), + renderSwitchItem('Logo', { + handler: (e) => { + baseHandler('showLogo', e); + }, + def: showLogo, + }), + renderSwitchItem('全屏内容', { + handler: (e) => { + baseHandler('fullContent', e); + }, + def: fullContent, + }), + renderSwitchItem('灰色模式', { + handler: (e) => { + baseHandler('grayMode', e); + }, + def: grayMode, + }), + renderSwitchItem('色弱模式', { + handler: (e) => { + baseHandler('colorWeak', e); + }, + def: colorWeak, + }), + ]; + } + function baseHandler(event: string, value: any) { + let config: DeepPartial = {}; + if (event === 'layout') { + const { mode, type, split } = value; + const splitOpt = split === undefined ? { split } : {}; + config = { + menuSetting: { + mode, + type, + ...splitOpt, + }, + }; + } + if (event === 'hasDrag') { + config = { + menuSetting: { + hasDrag: value, + }, + }; + } + if (event === 'openPageLoading') { + config = { + openPageLoading: value, + }; + } + if (event === 'topMenuAlign') { + config = { + menuSetting: { + topMenuAlign: value, + }, + }; + } + if (event === 'showBreadCrumb') { + config = { + showBreadCrumb: value, + }; + } + if (event === 'collapsed') { + config = { + menuSetting: { + collapsed: value, + }, + }; + } + if (event === 'menuWidth') { + config = { + menuSetting: { + menuWidth: value, + }, + }; + } + if (event === 'menuWidth') { + config = { + lockTime: value, + }; + } + if (event === 'showQuick') { + config = { + multiTabsSetting: { + showQuick: value, + }, + }; + } + if (event === 'showTabIcon') { + config = { + multiTabsSetting: { + showIcon: value, + }, + }; + } + if (event === 'contentMode') { + config = { + contentMode: value, + }; + } + if (event === 'menuTheme') { + config = { + menuSetting: { + theme: value, + }, + }; + } + if (event === 'splitMenu') { + config = { + menuSetting: { + split: value, + }, + }; + } + if (event === 'showMultiple') { + config = { + multiTabsSetting: { + show: value, + }, + }; + } + if (event === 'headerMenu') { + config = { + headerSetting: { + theme: value, + }, + }; + } + if (event === 'grayMode') { + config = { + grayMode: value, + }; + updateGrayMode(value); + } + if (event === 'colorWeak') { + config = { + colorWeak: value, + }; + updateColorWeak(value); + } + if (event === 'showLogo') { + config = { + showLogo: value, + }; + } + if (event === 'showSearch') { + config = { + menuSetting: { + showSearch: value, + }, + }; + } + if (event === 'showSidebar') { + config = { + menuSetting: { + show: value, + }, + }; + } + if (event === 'openRouterTransition') { + config = { + openRouterTransition: value, + }; + } + if (event === 'routerTransition') { + config = { + routerTransition: value, + }; + } + if (event === 'headerFixed') { + config = { + headerSetting: { + fixed: value, + }, + }; + } + if (event === 'fullContent') { + config = { + fullContent: value, + }; + } + if (event === 'showHeader') { + config = { + headerSetting: { + show: value, + }, + }; + } + appStore.commitProjectConfigState(config); + } + + function handleResetSetting() { + try { + appStore.commitProjectConfigState(defaultSetting); + const { colorWeak, grayMode } = defaultSetting; + // updateTheme(themeColor); + updateColorWeak(colorWeak); + updateGrayMode(grayMode); + createMessage.success('重置成功!'); + } catch (error) { + createMessage.error(error); + } + } + + function handleClearAndRedo() { + localStorage.clear(); + userStore.resumeAllState(); + location.reload(); + } + + function renderSelectItem(text: string, config?: SelectConfig) { + const { handler, def, disabled = false, options } = config || {}; + const opt = def ? { value: def, defaultValue: def } : {}; + return ( +
    + {text} + {/* @ts-ignore */} +