commit 28fda433174da990e1e629a98d998eff4c1c343b Author: XiaoHH Date: Mon Sep 28 15:27:49 2026 +0800 init diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4fc13c7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.{ts,js,vue,css}] +indent_size = 2 diff --git a/.env b/.env new file mode 100644 index 0000000..2dd71fd --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# 打包路径 根据项目不同按需配置 +VITE_BASE_URL = / +VITE_IS_REQUEST_PROXY = true +VITE_API_URL = https://service-bv448zsw-1257786608.gz.apigw.tencentcs.com +VITE_API_URL_PREFIX = /api \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..d475e5c --- /dev/null +++ b/.env.development @@ -0,0 +1,5 @@ +# 打包路径 +VITE_BASE_URL = / +VITE_IS_REQUEST_PROXY = true +VITE_API_URL = https://service-exndqyuk-1257786608.gz.apigw.tencentcs.com +VITE_API_URL_PREFIX = /api \ No newline at end of file diff --git a/.env.site b/.env.site new file mode 100644 index 0000000..7e1e855 --- /dev/null +++ b/.env.site @@ -0,0 +1,5 @@ +# 打包路径 根据项目不同按需配置 +VITE_BASE_URL = https://static.tdesign.tencent.com/starter/vue-next/ +VITE_IS_REQUEST_PROXY = true +VITE_API_URL = https://service-bv448zsw-1257786608.gz.apigw.tencentcs.com +VITE_API_URL_PREFIX = /api \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..d278ac2 --- /dev/null +++ b/.env.test @@ -0,0 +1,5 @@ +# 打包路径 根据项目不同按需配置 +VITE_BASE_URL = / +VITE_IS_REQUEST_PROXY = true +VITE_API_URL = https://service-exndqyuk-1257786608.gz.apigw.tencentcs.com +VITE_API_URL_PREFIX = /api \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..37494bc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* TEXT EOF=LF + +*.cmd TEXT EOF=CRLF +*.CMD TEXT EOF=CRLF +*.bat TEXT EOF=CRLF +*.BAT TEXT EOF=CRLF diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0db5414 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# OS specific files +.DS_Store + +# dependencies manager +node_modules/ +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# build files +es/ +lib/ +dist/ +typings/ + +_site +package +tmp* +temp* +coverage +test-report.html +.idea/ +yarn-error.log +*.zip +.history +.stylelintcache + +.env.local +.env.*.local + +# lock文件 请根据自身项目或团队需求选择具体的包管理工具 并移除具体的ignore的lock文件 +yarn.lock +pnpm-lock.yaml +package-lock.json diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..bbc8371 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,5 @@ +if [ "$OS" = "Windows_NT" ]; then + npx.cmd --no-install commitlint -e $GIT_PARAMS +else + npx --no-install commitlint -e $GIT_PARAMS +fi diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..c27d889 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +lint-staged diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100644 index 0000000..2d59bb1 --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1,11 @@ +case "$(uname -a)" in + *MINGW64*) exit 0 ;; +esac + +[ -n "$CI" ] && exit 0 + +if [ "$OS" = "Windows_NT" ]; then + exec < /dev/tty && npx.cmd git-cz --hook || true +else + exec < /dev/tty && npx git-cz --hook || true +fi diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..bf26ebd --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +shamefully-hoist = true +hoist = true +engine-strict = true diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..22227fe --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,39 @@ +export default { + // 一行最多 120 字符.. + printWidth: 120, + // 使用 2 个空格缩进 + tabWidth: 2, + // 不使用缩进符,而使用空格 + useTabs: false, + // 行尾需要有分号 + semi: true, + // 使用单引号 + singleQuote: true, + // 对象的 key 仅在必要时用引号 + quoteProps: 'as-needed', + // jsx 不使用单引号,而使用双引号 + jsxSingleQuote: false, + // 末尾需要有逗号 + trailingComma: 'all', + // 大括号内的首尾需要空格 + bracketSpacing: true, + // jsx 标签的反尖括号需要换行 + jsxBracketSameLine: false, + // 箭头函数,只有一个参数的时候,也需要括号 + arrowParens: 'always', + // 每个文件格式化的范围是文件的全部内容 + rangeStart: 0, + rangeEnd: Infinity, + // 不需要写文件开头的 @prettier + requirePragma: false, + // 不需要自动在文件开头插入 @prettier + insertPragma: false, + // 使用默认的折行标准 + proseWrap: 'preserve', + // 根据显示样式决定 html 要不要折行 + htmlWhitespaceSensitivity: 'css', + // vue 文件中的 script 和 style 内不用缩进 + vueIndentScriptAndStyle: false, + // 换行符使用 lf + endOfLine: 'lf', +}; diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 0000000..1b7da3c --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,8 @@ +# .stylelintignore +# 旧的不需打包的样式库 +*.min.css + +# 其他类型文件 +*.js +*.jpg +*.woff diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..940260d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b578ccc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,45 @@ +{ + "files.eol": "\n", + "editor.tabSize": 2, + "eslint.format.enable": true, + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue"], + "[vue]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "cSpell.words": ["tdesign", "tvision", "echarts", "nprogress", "commitlint", "stylelint", "pinia", "qrcode"], + "prettier.enable": false, + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" + }, + "eslint.rules.customizations": [ + { "rule": "style/*", "severity": "off", "fixable": true }, + { "rule": "format/*", "severity": "off", "fixable": true }, + { "rule": "*-indent", "severity": "off", "fixable": true }, + { "rule": "*-spacing", "severity": "off", "fixable": true }, + { "rule": "*-spaces", "severity": "off", "fixable": true }, + { "rule": "*-order", "severity": "off", "fixable": true }, + { "rule": "*-dangle", "severity": "off", "fixable": true }, + { "rule": "*-newline", "severity": "off", "fixable": true }, + { "rule": "*quotes", "severity": "off", "fixable": true }, + { "rule": "*semi", "severity": "off", "fixable": true } + ] +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81af071 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 TDesign + +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/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..b21d451 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,11 @@ +// commit-lint config +export default { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'types'], + ], + }, +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..401f45c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,151 @@ +import antfu from '@antfu/eslint-config'; +import { globalIgnores } from 'eslint/config'; +import prettier from 'eslint-plugin-prettier/recommended'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; +import vueCss from 'eslint-plugin-vue-scoped-css'; +import globals from 'globals'; +import typescript from 'typescript-eslint'; + +export default antfu( + { + typescript: true, + vue: true, + jsonc: false, + yaml: false, + markdown: false, + formatters: false, + }, + [ + ...vueCss.configs['flat/recommended'], + prettier, + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + ...globals.jest, + defineProps: 'readonly', + defineEmits: 'readonly', + }, + ecmaVersion: 6, + sourceType: 'module', + + parserOptions: { + parser: typescript.parser, + allowImportExportEverywhere: true, + + ecmaFeatures: { + jsx: true, + }, + }, + }, + + plugins: { + 'simple-import-sort': simpleImportSort, + }, + + settings: { + 'import/extensions': ['.js', '.jsx', '.ts', '.tsx'], + }, + + rules: { + /* Closed due to template running + * (Recommended to open!) + */ + 'no-console': 'off', + 'ts/no-explicit-any': 'off', + + /* Disallow person rules */ + 'antfu/top-level-function': 'off', + 'antfu/if-newline': 'off', + 'n/prefer-global/process': 'off', + + /* If you need control the imports sequence, must be off + * https://github.com/vuejs/vue-eslint-parser/issues/58 + */ + 'import/first': 'off', + + /* Allow start with _ */ + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + 'vue/no-unused-vars': [ + 'error', + { + ignorePattern: '^_', + }, + ], + // Using ts/no-unused-vars instead + 'no-unused-vars': 'off', + + /* Some variables are initialized in the function */ + '@typescript-eslint/no-use-before-define': 'off', + 'no-use-before-define': 'off', + + /* Disable antfu sort, use simple sort import */ + 'perfectionist/sort-imports': 'off', + 'perfectionist/sort-named-imports': 'off', + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + // Disable unused-imports rules in other presets + 'unused-imports/no-unused-imports': 'off', + 'unused-imports/no-unused-vars': 'off', + }, + }, + { + files: ['**/*.vue'], + + rules: { + 'vue/component-name-in-template-casing': ['error', 'kebab-case'], + 'vue/custom-event-name-casing': ['error', 'kebab-case'], + 'vue/block-order': [ + 'error', + { + order: ['template', 'script', 'style'], + }, + ], + 'vue/block-lang': [ + 'error', + { + script: { + lang: ['ts', 'tsx'], + }, + }, + ], + 'vue/multi-word-component-names': 'off', + 'vue/no-reserved-props': 'off', + 'vue/no-v-html': 'off', + + 'vue-scoped-css/no-parsing-error': 'off', + 'vue-scoped-css/no-unused-selector': 'off', + 'vue-scoped-css/enforce-style-type': [ + 'error', + { + allows: ['scoped'], + }, + ], + 'vue/padding-line-between-blocks': ['error', 'never'], + }, + }, + globalIgnores([ + '**/snapshot*', + '**/dist', + '**/lib', + '**/es', + '**/esm', + '**/node_modules', + 'src/_common', + '**/static', + '**/cypress', + 'script/test/cypress', + '**/_site', + '**/temp*', + '**/static/', + '!**/.prettierrc.js', + ]), + ], +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..587b9b1 --- /dev/null +++ b/index.html @@ -0,0 +1,25 @@ + + + + + + + TDesign Vue Next Starter + + +
+ + + + + + diff --git a/mock/index.ts b/mock/index.ts new file mode 100644 index 0000000..916c23f --- /dev/null +++ b/mock/index.ts @@ -0,0 +1,368 @@ +import Mock from 'mockjs'; +import type { MockMethod } from 'vite-plugin-mock'; + +export default [ + { + url: '/api/get-purchase-list', + method: 'get', + response: () => ({ + code: 0, + data: { + ...Mock.mock({ + 'list|1-100': [ + { + index: /S20201228115950\d\d\d/, + pdName: 'Macbook', + pdNum: 'p_tmp_60a637cd0d', + 'purchaseNum|1-100': 100, + adminName: '财务部111', + updateTime: '2020-05-20@date("HH:mm:ss")', + pdType: '电子产品', + }, + { + index: /S20201228115950\d\d\d/, + pdName: 'Macbook', + pdNum: 'p_tmp_60a637cd0d', + 'purchaseNum|1-100': 100, + adminName: '财务部', + updateTime: '2020-05-20@date("HH:mm:ss")', + }, + ], + }), + }, + }), + }, + { + url: '/api/get-list', + method: 'get', + response: () => ({ + code: 0, + data: { + ...Mock.mock({ + 'list|1-100': [ + { + 'index|+1': 1, + 'status|1': '@natural(0, 4)', + no: 'BH00@natural(01, 100)', + name: '@city()办公用品采购项目', + 'paymentType|1': '@natural(0, 1)', + 'contractType|1': '@natural(0, 2)', + updateTime: '2020-05-30 @date("HH:mm:ss")', + amount: '@natural(10, 500),000,000', + adminName: '@cname()', + }, + ], + }), + }, + }), + }, + { + url: '/api/detail-basic', + method: 'get', + response: () => ({ + code: 0, + data: { + ...Mock.mock({ + name: 'td_20023747', + loginType: 'Web', + currentRole: 'Admin', + rightsList: '通用权限', + userStatus: '启用', + language: '简体中文', + timeZone: '(GMT+08:00)中国时区—北京(Asia/Beijing)', + }), + }, + }), + }, + { + url: '/api/get-card-list', + method: 'get', + response: () => ({ + code: 0, + data: { + ...Mock.mock({ + 'list|48-50': [ + { + 'index|+1': 1, + isSetup: '@boolean', + 'type|1': '@natural(1, 5)', + 'banner|1': [ + 'https://tdesign.gtimg.com/starter/cloud-db.jpg', + 'https://tdesign.gtimg.com/starter/cloud-server.jpg', + 'https://tdesign.gtimg.com/starter/ssl.jpg', + 'https://tdesign.gtimg.com/starter/t-sec.jpg', + 'https://tdesign.gtimg.com/starter/face-recognition.jpg', + ], + 'name|1': ['人脸识别', 'SSL证书', 'CVM', '云数据库', 'T-Sec 云防火墙'], + 'description|1': [ + '基于腾讯优图强大的面部分析技术,提供包括人脸检测与分析、五官定位、人脸搜索、人脸比对、人脸', + '云硬盘为您提供用于CVM的持久性数据块级存储服务。云硬盘中的数据自动地可用区内以多副本冗', + 'SSL证书又叫服务器证书,腾讯云为您提供证书的一站式服务,包括免费、付费证书的申请、管理及部', + '腾讯安全云防火墙产品,是腾讯云安全团队结合云原生的优势,自主研发的SaaS化防火墙产品,无需客无需客无需客无需客无需客无需客无需客', + '云数据库MySQL为用户提供安全可靠,性能卓越、易于维护的企业级云数据库服务。', + ], + }, + ], + }), + }, + }), + }, + { + url: '/api/get-project-list', + method: 'get', + response: () => ({ + code: 0, + data: { + ...Mock.mock({ + 'list|1-50': [ + { + 'index|+1': 1, + adminPhone: '+86 13587609955', + updateTime: '2020-05-30 @date("HH:mm:ss")', + 'adminName|1': ['顾娟 ', '常刚', '郑洋'], + 'name|1': [ + '沧州市办公用品采购项目', + '红河哈尼族彝族自治州办公用品采购项目 ', + '铜川市办公用品采购项目', + '陇南市办公用品采购项目 ', + '六安市办公用品采购项目 ', + ], + }, + ], + }), + }, + }), + }, + { + url: '/api/post', + method: 'post', + timeout: 2000, + response: { + code: 0, + data: { + name: 'vben', + }, + }, + }, + { + url: '/api/get-menu-list-i18n', + method: 'get', + timeout: 2000, + response: { + code: 0, + data: { + ...Mock.mock({ + list: [ + { + path: '/list', + name: 'list', + component: 'LAYOUT', + redirect: '/list/base', + meta: { + title: { + zh_CN: '列表页', + en_US: 'List', + }, + icon: 'view-list', + }, + children: [ + { + path: 'base', + name: 'ListBase', + component: '/list/base/index', + meta: { + title: { + zh_CN: '基础列表页', + en_US: 'Base List', + }, + }, + }, + { + path: 'card', + name: 'ListCard', + component: '/list/card/index', + meta: { + title: { + zh_CN: '卡片列表页', + en_US: 'Card List', + }, + }, + }, + { + path: 'filter', + name: 'ListFilter', + component: '/list/filter/index', + meta: { + title: { + zh_CN: '筛选列表页', + en_US: 'Filter List', + }, + }, + }, + { + path: 'tree', + name: 'ListTree', + component: '/list/tree/index', + meta: { + title: { + zh_CN: '树状筛选列表页', + en_US: 'Tree List', + }, + }, + }, + ], + }, + { + path: '/form', + name: 'form', + component: 'LAYOUT', + redirect: '/form/base', + meta: { + title: { + zh_CN: '表单页', + en_US: 'Form', + }, + icon: 'edit-1', + }, + children: [ + { + path: 'base', + name: 'FormBase', + component: '/form/base/index', + meta: { + title: { + zh_CN: '基础表单页', + en_US: 'Base Form', + }, + }, + }, + { + path: 'step', + name: 'FormStep', + component: '/form/step/index', + meta: { + title: { + zh_CN: '分步表单页', + en_US: 'Step Form', + }, + }, + }, + ], + }, + { + path: '/detail', + name: 'detail', + component: 'LAYOUT', + redirect: '/detail/base', + meta: { + title: { + zh_CN: '详情页', + en_US: 'Detail', + }, + icon: 'layers', + }, + children: [ + { + path: 'base', + name: 'DetailBase', + component: '/detail/base/index', + meta: { + title: { + zh_CN: '基础详情页', + en_US: 'Base Detail', + }, + }, + }, + { + path: 'advanced', + name: 'DetailAdvanced', + component: '/detail/advanced/index', + meta: { + title: { + zh_CN: '多卡片详情页', + en_US: 'Card Detail', + }, + }, + }, + { + path: 'deploy', + name: 'DetailDeploy', + component: '/detail/deploy/index', + meta: { + title: { + zh_CN: '数据详情页', + en_US: 'Data Detail', + }, + }, + }, + { + path: 'secondary', + name: 'DetailSecondary', + component: '/detail/secondary/index', + meta: { + title: { + zh_CN: '二级详情页', + en_US: 'Secondary Detail', + }, + }, + }, + ], + }, + { + path: '/frame', + name: 'Frame', + component: 'Layout', + redirect: '/frame/doc', + meta: { + icon: 'internet', + title: { + zh_CN: '外部页面', + en_US: 'External', + }, + }, + children: [ + { + path: 'doc', + name: 'Doc', + component: 'IFrame', + meta: { + frameSrc: 'https://tdesign.tencent.com/starter/docs/vue-next/get-started', + title: { + zh_CN: '使用文档(内嵌)', + en_US: 'Documentation(IFrame)', + }, + }, + }, + { + path: 'TDesign', + name: 'TDesign', + component: 'IFrame', + meta: { + frameSrc: 'https://tdesign.tencent.com/vue-next/getting-started', + title: { + zh_CN: 'TDesign 文档(内嵌)', + en_US: 'TDesign (IFrame)', + }, + }, + }, + { + path: 'TDesign2', + name: 'TDesign2', + component: 'IFrame', + meta: { + frameSrc: 'https://tdesign.tencent.com/vue-next/getting-started', + frameBlank: true, + title: { + zh_CN: 'TDesign 文档(外链', + en_US: 'TDesign Doc(Link)', + }, + }, + }, + ], + }, + ], + }), + }, + }, + }, +] as MockMethod[]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..2ff0f6f --- /dev/null +++ b/package.json @@ -0,0 +1,105 @@ +{ + "name": "@xiaohh/xiaohh-tdesign-ui", + "version": "0.0.1", + "type": "module", + "repository": { + "type": "git", + "url": "" + }, + "scripts": { + "dev:mock": "vite --open --mode mock", + "dev": "vite --open --mode development", + "dev:linux": "vite --mode development", + "build:test": "vite build --mode test", + "build": "vue-tsc --noEmit && vite build --mode release", + "build:type": "vue-tsc --noEmit", + "build:site": "vue-tsc --noEmit && vite build --mode site", + "preview": "vite preview", + "lint": "eslint --ext .vue,.js,.jsx,.ts,.tsx ./ --max-warnings 0", + "lint:fix": "eslint --ext .vue,.js,jsx,.ts,.tsx ./ --max-warnings 0 --fix", + "stylelint": "stylelint src/**/*.{html,vue,css,sass,less}", + "stylelint:fix": "stylelint --fix src/**/*.{html,vue,css,sass,less}", + "prepare": "husky", + "site:preview": "npm run build && cp -r dist _site", + "test": "echo \"no test specified,work in process\"", + "test:coverage": "echo \"no test:coverage specified,work in process\"", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "dependencies": { + "@vueuse/core": "^15.0.0", + "axios": "^1.20.0", + "dayjs": "^1.11.23", + "echarts": "^6.1.0", + "lightningcss": "^1.33.0", + "lodash": "^4.18.1", + "nprogress": "^0.2.0", + "pinia": "^4.0.3", + "pinia-plugin-persistedstate": "^4.7.1", + "qs": "^6.16.0", + "tdesign-icons-vue-next": "^0.4.11", + "tdesign-vue-next": "^1.20.8", + "tvision-color": "^1.6.0", + "vue": "^3.5.43", + "vue-i18n": "^11.4.12", + "vue-router": "^5.3.1" + }, + "devDependencies": { + "@antfu/eslint-config": "^9.5.1", + "@commitlint/cli": "^21.2.3", + "@commitlint/config-conventional": "^21.2.3", + "@types/echarts": "^4.9.22", + "@types/lodash": "^4.17.25", + "@types/mockjs": "^1.0.10", + "@types/node": "^24.19.0", + "@types/nprogress": "^0.2.3", + "@types/qs": "^6.15.1", + "@vitejs/plugin-vue": "^6.0.9", + "@vitejs/plugin-vue-jsx": "^5.1.6", + "@vue/compiler-sfc": "~3.5.42", + "commitizen": "^4.3.2", + "cz-conventional-changelog": "^3.3.0", + "esbuild": "^0.28.2", + "eslint": "^10.11.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-simple-import-sort": "^14.0.0", + "eslint-plugin-vue-scoped-css": "^3.1.3", + "globals": "^17.12.0", + "husky": "^9.1.7", + "less": "^4.9.1", + "lint-staged": "^17.6.0", + "mockjs": "^1.1.0", + "postcss-html": "^2.0.0", + "postcss-less": "^6.0.0", + "prettier": "^3.9.9", + "rspack-resolver": "^1.3.0", + "stylelint": "~17.15.0", + "stylelint-config-standard": "^40.0.0", + "stylelint-order": "~8.1.1", + "typescript": "~6.0.3", + "typescript-eslint": "^8.70.1", + "vite": "^8.3.1", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-mock": "^3.0.2", + "vite-svg-loader": "^5.1.3", + "vue-tsc": "^3.3.11" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + }, + "lint-staged": { + "*.{js,jsx,vue,ts,tsx}": [ + "prettier --write", + "npm run lint:fix" + ], + "*.{html,vue,css,sass,less}": [ + "npm run stylelint:fix" + ] + }, + "engines": { + "node": ">=24.19.0" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..086ac80 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 0000000..b291f91 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/api/detail.ts b/src/api/detail.ts new file mode 100644 index 0000000..858c7ff --- /dev/null +++ b/src/api/detail.ts @@ -0,0 +1,19 @@ +import type { ProjectListResult, PurchaseListResult } from '@/api/model/detailModel'; +import { request } from '@/utils/request'; + +const Api = { + PurchaseList: '/get-purchase-list', + ProjectList: '/get-project-list', +}; + +export function getPurchaseList() { + return request.get({ + url: Api.PurchaseList, + }); +} + +export function getProjectList() { + return request.get({ + url: Api.ProjectList, + }); +} diff --git a/src/api/list.ts b/src/api/list.ts new file mode 100644 index 0000000..ab30e2b --- /dev/null +++ b/src/api/list.ts @@ -0,0 +1,19 @@ +import type { CardListResult, ListResult } from '@/api/model/listModel'; +import { request } from '@/utils/request'; + +const Api = { + BaseList: '/get-list', + CardList: '/get-card-list', +}; + +export function getList() { + return request.get({ + url: Api.BaseList, + }); +} + +export function getCardList() { + return request.get({ + url: Api.CardList, + }); +} diff --git a/src/api/model/detailModel.ts b/src/api/model/detailModel.ts new file mode 100644 index 0000000..c144b48 --- /dev/null +++ b/src/api/model/detailModel.ts @@ -0,0 +1,23 @@ +export interface PurchaseListResult { + list: Array; +} +export interface PurchaseInfo { + adminName: string; + index: string; + pdName: string; + pdNum: string; + pdType: string; + purchaseNum: number; + updateTime: Date; +} + +export interface ProjectListResult { + list: Array; +} +export interface ProjectInfo { + adminName: string; + adminPhone: string; + index: number; + name: string; + updateTime: Date; +} diff --git a/src/api/model/listModel.ts b/src/api/model/listModel.ts new file mode 100644 index 0000000..453114d --- /dev/null +++ b/src/api/model/listModel.ts @@ -0,0 +1,26 @@ +export interface ListResult { + list: Array; +} +export interface ListModel { + adminName: string; + amount: string; + contractType: number; + index: number; + name: string; + no: string; + paymentType: number; + status: number; + updateTime: Date; +} + +export interface CardListResult { + list: Array; +} +export interface CardList { + banner: string; + description: string; + index: number; + isSetup: boolean; + name: string; + type: number; +} diff --git a/src/api/model/permissionModel.ts b/src/api/model/permissionModel.ts new file mode 100644 index 0000000..d25fb7a --- /dev/null +++ b/src/api/model/permissionModel.ts @@ -0,0 +1,19 @@ +import type { defineComponent } from 'vue'; +import type { RouteMeta } from 'vue-router'; + +export interface MenuListResult { + list: Array; +} + +export type Component = + ReturnType | (() => Promise) | (() => Promise); + +export interface RouteItem { + path: string; + name: string; + component?: Component | string; + components?: Component; + redirect?: string; + meta: RouteMeta; + children?: Array; +} diff --git a/src/api/permission.ts b/src/api/permission.ts new file mode 100644 index 0000000..a6f50d8 --- /dev/null +++ b/src/api/permission.ts @@ -0,0 +1,12 @@ +import type { MenuListResult } from '@/api/model/permissionModel'; +import { request } from '@/utils/request'; + +const Api = { + MenuList: '/get-menu-list-i18n', +}; + +export function getMenuList() { + return request.get({ + url: Api.MenuList, + }); +} diff --git a/src/assets/assets-empty.svg b/src/assets/assets-empty.svg new file mode 100644 index 0000000..374d27d --- /dev/null +++ b/src/assets/assets-empty.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/assets-login-bg-black.png b/src/assets/assets-login-bg-black.png new file mode 100644 index 0000000..b4dde4b Binary files /dev/null and b/src/assets/assets-login-bg-black.png differ diff --git a/src/assets/assets-login-bg-white.png b/src/assets/assets-login-bg-white.png new file mode 100644 index 0000000..4275549 Binary files /dev/null and b/src/assets/assets-login-bg-white.png differ diff --git a/src/assets/assets-logo-full.svg b/src/assets/assets-logo-full.svg new file mode 100644 index 0000000..94f0049 --- /dev/null +++ b/src/assets/assets-logo-full.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/assets-product-1.svg b/src/assets/assets-product-1.svg new file mode 100644 index 0000000..07c105e --- /dev/null +++ b/src/assets/assets-product-1.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/assets/assets-product-2.svg b/src/assets/assets-product-2.svg new file mode 100644 index 0000000..f6a13ac --- /dev/null +++ b/src/assets/assets-product-2.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/assets/assets-product-3.svg b/src/assets/assets-product-3.svg new file mode 100644 index 0000000..96f8c0e --- /dev/null +++ b/src/assets/assets-product-3.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/assets/assets-product-4.svg b/src/assets/assets-product-4.svg new file mode 100644 index 0000000..6005e4c --- /dev/null +++ b/src/assets/assets-product-4.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/src/assets/assets-result-403.svg b/src/assets/assets-result-403.svg new file mode 100644 index 0000000..343a25e --- /dev/null +++ b/src/assets/assets-result-403.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/assets-result-404.svg b/src/assets/assets-result-404.svg new file mode 100644 index 0000000..db1cb36 --- /dev/null +++ b/src/assets/assets-result-404.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/assets-result-500.svg b/src/assets/assets-result-500.svg new file mode 100644 index 0000000..08784af --- /dev/null +++ b/src/assets/assets-result-500.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/assets-result-ie.svg b/src/assets/assets-result-ie.svg new file mode 100644 index 0000000..ebaf5ba --- /dev/null +++ b/src/assets/assets-result-ie.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/assets-result-maintenance.svg b/src/assets/assets-result-maintenance.svg new file mode 100644 index 0000000..388fd0b --- /dev/null +++ b/src/assets/assets-result-maintenance.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/assets-result-wifi.svg b/src/assets/assets-result-wifi.svg new file mode 100644 index 0000000..eeee8b5 --- /dev/null +++ b/src/assets/assets-result-wifi.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/assets-setting-auto.svg b/src/assets/assets-setting-auto.svg new file mode 100644 index 0000000..66f57de --- /dev/null +++ b/src/assets/assets-setting-auto.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/assets-setting-dark.svg b/src/assets/assets-setting-dark.svg new file mode 100644 index 0000000..0f15649 --- /dev/null +++ b/src/assets/assets-setting-dark.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/assets-setting-light.svg b/src/assets/assets-setting-light.svg new file mode 100644 index 0000000..ffa8f34 --- /dev/null +++ b/src/assets/assets-setting-light.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/assets-t-logo.svg b/src/assets/assets-t-logo.svg new file mode 100644 index 0000000..3f95cba --- /dev/null +++ b/src/assets/assets-t-logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/assets-tencent-logo.png b/src/assets/assets-tencent-logo.png new file mode 100644 index 0000000..26fdb92 Binary files /dev/null and b/src/assets/assets-tencent-logo.png differ diff --git a/src/components/LanguageSwitcher.vue b/src/components/LanguageSwitcher.vue new file mode 100644 index 0000000..945f08b --- /dev/null +++ b/src/components/LanguageSwitcher.vue @@ -0,0 +1,24 @@ + + diff --git a/src/components/color/index.vue b/src/components/color/index.vue new file mode 100644 index 0000000..24fe7fc --- /dev/null +++ b/src/components/color/index.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/components/common-table/index.vue b/src/components/common-table/index.vue new file mode 100644 index 0000000..fc09588 --- /dev/null +++ b/src/components/common-table/index.vue @@ -0,0 +1,339 @@ + + + diff --git a/src/components/product-card/index.vue b/src/components/product-card/index.vue new file mode 100644 index 0000000..e6112b8 --- /dev/null +++ b/src/components/product-card/index.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/components/result/index.vue b/src/components/result/index.vue new file mode 100644 index 0000000..1ac0faa --- /dev/null +++ b/src/components/result/index.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/components/thumbnail/index.vue b/src/components/thumbnail/index.vue new file mode 100644 index 0000000..8f1437d --- /dev/null +++ b/src/components/thumbnail/index.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/trend/index.vue b/src/components/trend/index.vue new file mode 100644 index 0000000..d7ce2df --- /dev/null +++ b/src/components/trend/index.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/config/color.ts b/src/config/color.ts new file mode 100644 index 0000000..300ac1d --- /dev/null +++ b/src/config/color.ts @@ -0,0 +1,30 @@ +export type TColorToken = Record; +export type TColorSeries = Record; + +// TODO: 中性色暂时固定 待tvision-color生成带色彩倾向的中性色 +export const LIGHT_CHART_COLORS = { + textColor: 'rgba(0, 0, 0, 0.9)', + placeholderColor: 'rgba(0, 0, 0, 0.35)', + borderColor: '#dcdcdc', + containerColor: '#fff', +}; + +export const DARK_CHART_COLORS = { + textColor: 'rgba(255, 255, 255, 0.9)', + placeholderColor: 'rgba(255, 255, 255, 0.35)', + borderColor: '#5e5e5e', + containerColor: '#242424', +}; + +export type TChartColor = typeof LIGHT_CHART_COLORS; + +export const DEFAULT_COLOR_OPTIONS = [ + '#0052D9', + '#0594FA', + '#00A870', + '#EBB105', + '#ED7B2F', + '#E34D59', + '#ED49B4', + '#834EC2', +]; diff --git a/src/config/global.ts b/src/config/global.ts new file mode 100644 index 0000000..67aba60 --- /dev/null +++ b/src/config/global.ts @@ -0,0 +1 @@ +export const prefix = 'tdesign-starter'; diff --git a/src/config/style.ts b/src/config/style.ts new file mode 100644 index 0000000..1d2e1f8 --- /dev/null +++ b/src/config/style.ts @@ -0,0 +1,16 @@ +export default { + showFooter: true, + isSidebarCompact: false, + showBreadcrumb: false, + menuAutoCollapsed: false, + mode: 'light', + layout: 'side', + splitMenu: false, + sideMode: 'light', + isFooterAside: false, + isSidebarFixed: true, + isHeaderFixed: true, + isUseTabsRouter: false, + showHeader: true, + brandTheme: '#0052D9', +}; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..6cea6f4 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,37 @@ +// 合同状态枚举 +export const CONTRACT_STATUS = { + FAIL: 0, + AUDIT_PENDING: 1, + EXEC_PENDING: 2, + EXECUTING: 3, + FINISH: 4, +}; + +// 合同类型枚举 +export const CONTRACT_TYPES = { + MAIN: 0, + SUB: 1, + SUPPLEMENT: 2, +}; + +// 合同收付类型枚举 +export const CONTRACT_PAYMENT_TYPES = { + PAYMENT: 0, + RECEIPT: 1, +}; + +// 标签类型 +type TagTheme = 'default' | 'success' | 'primary' | 'warning' | 'danger'; +// 通知的优先级对应的标签类型 +export const NOTIFICATION_TYPES: Map = new Map([ + ['low', 'primary'], + ['middle', 'warning'], + ['high', 'danger'], +]); + +// 通用请求头 +export enum ContentTypeEnum { + Json = 'application/json;charset=UTF-8', + FormURLEncoded = 'application/x-www-form-urlencoded;charset=UTF-8', + FormData = 'multipart/form-data;charset=UTF-8', +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..e9a0c21 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,61 @@ +import * as echarts from 'echarts/core'; +import type { Ref, ShallowRef } from 'vue'; +import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; + +/** + * eChart hook + * @param domId + */ +export const useChart = (domId: string): ShallowRef => { + let chartContainer: HTMLCanvasElement; + const selfChart = shallowRef(); + const updateContainer = () => { + selfChart.value.resize({ + width: chartContainer.clientWidth, + height: chartContainer.clientHeight, + }); + }; + + onMounted(() => { + if (!chartContainer) { + chartContainer = document.getElementById(domId) as HTMLCanvasElement; + } + selfChart.value = echarts.init(chartContainer); + }); + + window.addEventListener('resize', updateContainer, false); + + onUnmounted(() => { + window.removeEventListener('resize', updateContainer); + }); + + return selfChart; +}; + +/** + * counter utils + * @param duration + * @returns counter + */ +export const useCounter = (duration = 60): [Ref, () => void] => { + let intervalTimer: ReturnType; + onUnmounted(() => { + clearInterval(intervalTimer); + }); + const countDown = ref(0); + + return [ + countDown, + () => { + countDown.value = duration; + intervalTimer = setInterval(() => { + if (countDown.value > 0) { + countDown.value -= 1; + } else { + clearInterval(intervalTimer); + countDown.value = 0; + } + }, 1000); + }, + ]; +}; diff --git a/src/layouts/blank.vue b/src/layouts/blank.vue new file mode 100644 index 0000000..dcdc217 --- /dev/null +++ b/src/layouts/blank.vue @@ -0,0 +1,12 @@ + + diff --git a/src/layouts/components/Breadcrumb.vue b/src/layouts/components/Breadcrumb.vue new file mode 100644 index 0000000..627abc5 --- /dev/null +++ b/src/layouts/components/Breadcrumb.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/layouts/components/Content.vue b/src/layouts/components/Content.vue new file mode 100644 index 0000000..da74176 --- /dev/null +++ b/src/layouts/components/Content.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/layouts/components/Footer.vue b/src/layouts/components/Footer.vue new file mode 100644 index 0000000..19e01e4 --- /dev/null +++ b/src/layouts/components/Footer.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/layouts/components/FrameBlank.vue b/src/layouts/components/FrameBlank.vue new file mode 100644 index 0000000..b284429 --- /dev/null +++ b/src/layouts/components/FrameBlank.vue @@ -0,0 +1,10 @@ + + diff --git a/src/layouts/components/FrameContent.vue b/src/layouts/components/FrameContent.vue new file mode 100644 index 0000000..b709fe2 --- /dev/null +++ b/src/layouts/components/FrameContent.vue @@ -0,0 +1,106 @@ + + + diff --git a/src/layouts/components/Header.vue b/src/layouts/components/Header.vue new file mode 100644 index 0000000..173c7e6 --- /dev/null +++ b/src/layouts/components/Header.vue @@ -0,0 +1,297 @@ + + + + + diff --git a/src/layouts/components/LayoutContent.vue b/src/layouts/components/LayoutContent.vue new file mode 100644 index 0000000..754665e --- /dev/null +++ b/src/layouts/components/LayoutContent.vue @@ -0,0 +1,181 @@ + + diff --git a/src/layouts/components/LayoutHeader.vue b/src/layouts/components/LayoutHeader.vue new file mode 100644 index 0000000..32f683d --- /dev/null +++ b/src/layouts/components/LayoutHeader.vue @@ -0,0 +1,36 @@ + + diff --git a/src/layouts/components/LayoutSideNav.vue b/src/layouts/components/LayoutSideNav.vue new file mode 100644 index 0000000..661b63d --- /dev/null +++ b/src/layouts/components/LayoutSideNav.vue @@ -0,0 +1,45 @@ + + diff --git a/src/layouts/components/MenuContent.vue b/src/layouts/components/MenuContent.vue new file mode 100644 index 0000000..46645dd --- /dev/null +++ b/src/layouts/components/MenuContent.vue @@ -0,0 +1,111 @@ + + diff --git a/src/layouts/components/Notice.vue b/src/layouts/components/Notice.vue new file mode 100644 index 0000000..5dd4c90 --- /dev/null +++ b/src/layouts/components/Notice.vue @@ -0,0 +1,192 @@ + + + diff --git a/src/layouts/components/Search.vue b/src/layouts/components/Search.vue new file mode 100644 index 0000000..11a7b9b --- /dev/null +++ b/src/layouts/components/Search.vue @@ -0,0 +1,136 @@ + + + diff --git a/src/layouts/components/SideNav.vue b/src/layouts/components/SideNav.vue new file mode 100644 index 0000000..78db9d9 --- /dev/null +++ b/src/layouts/components/SideNav.vue @@ -0,0 +1,191 @@ + + + diff --git a/src/layouts/frame/index.vue b/src/layouts/frame/index.vue new file mode 100644 index 0000000..a57d651 --- /dev/null +++ b/src/layouts/frame/index.vue @@ -0,0 +1,29 @@ + + diff --git a/src/layouts/frame/useFrameKeepAlive.ts b/src/layouts/frame/useFrameKeepAlive.ts new file mode 100644 index 0000000..1fd228d --- /dev/null +++ b/src/layouts/frame/useFrameKeepAlive.ts @@ -0,0 +1,54 @@ +import uniqBy from 'lodash/uniqBy'; +import { computed, toRaw, unref } from 'vue'; +import { useRouter } from 'vue-router'; + +import { useSettingStore, useTabsRouterStore } from '@/store'; +import type { MenuRoute } from '@/types/interface'; + +export function useFrameKeepAlive() { + const router = useRouter(); + const { currentRoute } = router; + const { isUseTabsRouter } = useSettingStore(); + const tabStore = useTabsRouterStore(); + const getFramePages = computed(() => { + const ret = getAllFramePages(toRaw(router.getRoutes()) as unknown as MenuRoute[]) || []; + return ret; + }); + + const getOpenTabList = computed((): string[] => { + return tabStore.tabRouters.reduce((prev: string[], next) => { + if (next.meta && Reflect.has(next.meta, 'frameSrc')) { + prev.push(next.name as string); + } + return prev; + }, []); + }); + + function getAllFramePages(routes: MenuRoute[]): MenuRoute[] { + let res: MenuRoute[] = []; + for (const route of routes) { + const { meta: { frameSrc, frameBlank } = {}, children } = route; + if (frameSrc && !frameBlank) { + res.push(route); + } + if (children && children.length) { + res.push(...getAllFramePages(children)); + } + } + res = uniqBy(res, 'name'); + return res; + } + + function showIframe(item: MenuRoute) { + return item.name === unref(currentRoute).name; + } + + function hasRenderFrame(name: string) { + if (!unref(isUseTabsRouter)) { + return router.currentRoute.value.name === name; + } + return unref(getOpenTabList).includes(name); + } + + return { hasRenderFrame, getFramePages, showIframe, getAllFramePages }; +} diff --git a/src/layouts/index.vue b/src/layouts/index.vue new file mode 100644 index 0000000..4515e59 --- /dev/null +++ b/src/layouts/index.vue @@ -0,0 +1,75 @@ + + + diff --git a/src/layouts/setting.vue b/src/layouts/setting.vue new file mode 100644 index 0000000..b228330 --- /dev/null +++ b/src/layouts/setting.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/src/locales/index.ts b/src/locales/index.ts new file mode 100644 index 0000000..14024c3 --- /dev/null +++ b/src/locales/index.ts @@ -0,0 +1,60 @@ +import type { DropdownOption } from 'tdesign-vue-next'; +import en_US from 'tdesign-vue-next/es/locale/en_US'; +import zh_CN from 'tdesign-vue-next/es/locale/zh_CN'; +import { computed } from 'vue'; +import type { I18nOptions } from 'vue-i18n'; +import { createI18n } from 'vue-i18n'; + +export const localeConfigKey = 'tdesign-starter-locale'; + +// 定义支持的语言列表,添加新语言时只需在此处添加 +export const supportedLocales = ['zh_CN', 'en_US'] as const; +export type SupportedLocale = (typeof supportedLocales)[number]; + +// 多语言标题类型,用于路由 meta.title 等场景 +export type LocalizedTitle = Record; + +const tdesignLocaleMap: Record = { zh_CN, en_US }; + +const langModules = import.meta.glob<{ default: Record }>('./lang/*.json', { eager: true }); + +const langCode: SupportedLocale[] = []; +const messages: I18nOptions['messages'] = {}; +const langList: DropdownOption[] = []; + +Object.entries(langModules).forEach(([path, module]) => { + const code = path.match(/\.\/lang\/([^.]+)\.json$/)?.[1] as SupportedLocale | undefined; + if (!code || !supportedLocales.includes(code)) return; + langCode.push(code); + messages[code] = { ...module.default, componentsLocale: tdesignLocaleMap[code] }; + langList.push({ content: module.default.lang as string, value: code }); +}); + +export { langCode }; + +// 获取初始语言:优先本地存储,其次浏览器偏好,最后默认中文 +const getInitialLocale = (): SupportedLocale => { + const stored = localStorage.getItem(localeConfigKey); + if (stored && supportedLocales.includes(stored as SupportedLocale)) { + return stored as SupportedLocale; + } + const preferred = navigator.languages?.[0]?.replace(/-/g, '_'); + if (preferred && supportedLocales.includes(preferred as SupportedLocale)) { + return preferred as SupportedLocale; + } + return 'zh_CN'; +}; + +const initialLocale = getInitialLocale(); + +export const i18n = createI18n({ + legacy: false, + locale: initialLocale, + fallbackLocale: 'zh_CN', + messages, + globalInjection: true, +}); + +export const languageList = computed(() => langList); +export const { t } = i18n.global; +export default i18n; diff --git a/src/locales/lang/en_US.json b/src/locales/lang/en_US.json new file mode 100644 index 0000000..8bd410d --- /dev/null +++ b/src/locales/lang/en_US.json @@ -0,0 +1,752 @@ +{ + "lang": "English", + "common": { + "appName": "TDesign Starter", + "copyright": "Copyright © 2021-2025 Tencent. All Rights Reserved", + "conjunction": "and", + "admin": "admin" + }, + "components": { + "isSetup": { + "on": "Enabled", + "off": "Disabled" + }, + "manage": "Manage", + "delete": "Delete", + "deleteSuccess": "Deleted successfully", + "copySuccess": "Copied successfully", + "copyFail": "Copy failed", + "submitSuccess": "Submitted successfully", + "commonTable": { + "contractName": "Name", + "contractStatus": "Status", + "contractNum": "Number", + "contractType": "Type", + "contractPayType": "Pay Type", + "contractAmount": "Amount", + "contractNamePlaceholder": "enter contract name", + "contractStatusPlaceholder": "enter contract status", + "contractNumPlaceholder": "enter contract number", + "contractTypePlaceholder": "enter contract type", + "operation": "Operation", + "detail": "detail", + "delete": "delete", + "placeholder": "please enter to search", + "contractStatusEnum": { + "fail": "fail", + "audit": "audit", + "executing": "executing", + "pending": "pending", + "finish": "finish" + }, + "contractTypeEnum": { + "main": "main", + "sub": "sub", + "supplement": "supplement" + }, + "reset": "reset", + "query": "query" + } + }, + "layout": { + "header": { + "code": "Code Repository", + "help": "Document", + "user": "Profile", + "signOut": "Sign Out", + "setting": "Setting" + }, + "notice": { + "title": "Notification Center", + "clear": "Clear", + "setRead": "Set Read", + "empty": "Empty", + "emptyNotice": "No Notice", + "viewAll": "View All" + }, + "tagTabs": { + "closeOther": "close other", + "closeLeft": "close left", + "closeRight": "close right", + "refresh": "refresh" + }, + "searchPlaceholder": "Enter search content", + "search": { + "placeholder": "Enter search content" + }, + "setting": { + "title": "Setting", + "theme": { + "mode": "Theme Mode", + "color": "Theme Color", + "options": { + "light": "Light", + "dark": "Dark ", + "auto": "Follow System" + } + }, + "navigationLayout": "Navigation Layout", + "sideMode": "Side Menu Mode", + "splitMenu": "Split Menu (Only Mix mode)", + "fixedSidebar": "Fixed Sidebar", + "element": { + "title": "Element Switch", + "showHeader": "Show Header", + "showBreadcrumb": "Show Breadcrumb", + "showFooter": "Show Footer", + "useTagTabs": "Use Tag Tabs", + "menuAutoCollapsed": "Menu Auto Collapsed" + }, + "tips": "Please copy and manually modify the configuration file: /src/config/style.ts", + "copy": { + "title": "Copy", + "success": "copied", + "fail": "fail to copy" + } + } + }, + "constants": { + "contract": { + "name": "Name", + "status": "Status", + "num": "Number", + "type": "Type", + "typePlaceholder": "Please enter type", + "payType": "Pay Type", + "amount": "Amount", + "amountPlaceholder": "Please enter amount", + "signDate": "Sign Date", + "effectiveDate": "Effective Date", + "endDate": "End Date", + "createDate": "Create Date", + "attachment": "Attachment", + "company": "Company", + "employee": "Employee", + "pay": "pay", + "receive": "received", + "remark": "remark", + "statusOptions": { + "fail": "Failure", + "auditPending": "Pending audit", + "execPending": "Pending performance", + "executing": "Successful", + "finish": "Finish" + }, + "typeOptions": { + "main": "Master contract", + "sub": "Subcontract", + "supplement": "Supplementary contract" + } + } + }, + "pages": { + "dashboardBase": { + "title": "Overview Dashboard", + "outputOverview": { + "title": "In/Out Overview", + "subtitle": "(pieces)", + "export": "Export data", + "month": { + "input": "Total in store this month", + "output": "Total out store this month" + }, + "since": "Since last week" + }, + "rankList": { + "title": "Sales order ranking", + "week": "This week", + "month": "Latest 3 months", + "info": "Detail" + }, + "topPanel": { + "card1": "Total Revenue", + "card2": "Total Refund", + "card3": "Active User(s)", + "card4": "Generate Order(s)", + "cardTips": "since last week", + "analysis": { + "title": "Analysis Data", + "unit": "ten thousand yuan", + "series1": "this month", + "series2": "last month", + "channels": "Sales Channels", + "channel1": "online", + "channel2": "shop", + "channelTips": " sales ratio" + } + }, + "saleColumns": { + "index": "Ranking", + "productName": "Customer", + "growUp": "Grow up", + "count": "Count", + "operation": "Operation" + }, + "buyColumns": { + "index": "Ranking", + "productName": "Supplier", + "growUp": "Grow up", + "count": "Count", + "operation": "Operation" + }, + "saleTend": { + "item1": "State Grid Corporation of China", + "item2": "Shenzhen Gas Group Co., Ltd", + "item3": "State Administration of Tobacco Monopoly", + "item4": "China Telecom Group Co., Ltd", + "item5": "China Mobile Communications Group Co., Ltd", + "item6": "Xinyu City Office Supplies Procurement Project" + }, + "buyTend": { + "item1": "Tencent Technology (Shenzhen) Co., Ltd", + "item2": "Darunfa Co., Ltd", + "item3": "Sichuan Haidilao Co., Ltd", + "item4": "Sony (China) Co., Ltd", + "item5": "Panasonic Electronics (China) Co., Ltd", + "item6": "Xinyu City Office Supplies Procurement Project" + }, + "chart": { + "week1": "MON", + "week2": "TUE", + "week3": "WED", + "week4": "THU", + "week5": "FRI", + "week6": "SAT", + "week7": "SUN", + "max": "Max", + "min": "Min", + "thisMonth": "this month", + "lastMonth": "last month" + } + }, + "dashboardDetail": { + "topPanel": { + "title": "Purchase applications for this month", + "quarter": "Quarter on quarter", + "paneList": { + "totalRequest": "Apply count", + "suppliers": "Number of Suppliers", + "productCategory": "Product Category", + "applicant": "Number of Application", + "completionRate": "Completion Rate(%)", + "arrivalRate": "Arrival Rate(%)" + } + }, + "procurement": { + "title": "Trends in purchase requisitions for goods", + "goods": { + "cup": "cup", + "tea": "tea", + "honey": "honey", + "flour": "flour", + "coffeeMachine": "coffee machine", + "massageMachine": "massage machine" + } + }, + "ssl": "SSL certificate", + "sslDescription": "SSL certificate, also known as a server certificate, is a digital certificate that authenticates the identity of a website and encrypts information sent to the server using SSL technology. Tencent Cloud provides you with a one-stop service for SSL certificates, including application, management, and deployment of both free and paid certificates.", + "satisfaction": { + "title": "distribution of satisfaction levels for purchased goods", + "export": "export data" + }, + "chart": { + "week1": "MON", + "week2": "TUE", + "week3": "WED", + "week4": "THU", + "week5": "FRI", + "week6": "SAT", + "week7": "SUN", + "max": "Max", + "min": "Min", + "thisMonth": "this month", + "lastMonth": "last month" + } + }, + "detailBase": { + "baseInfo": { + "title": "Base Info" + }, + "sampleData": { + "contractName": "Office Supplies Procurement Project", + "status": "In Progress", + "company": "Tencent Technology (Shenzhen) Co., Ltd", + "employee": "Osun", + "fileName": "Office Supplies Procurement Contract.pdf" + }, + "changelog": { + "title": "Changelog", + "step1": { + "title": "upload file", + "subtitle": "subtitle" + }, + "step2": { + "title": "modify contract amount", + "subtitle": "subtitle" + }, + "step3": { + "title": "create contract", + "desc": "administrator" + } + } + }, + "detailCard": { + "baseInfo": { + "title": "Base Info" + }, + "invoice": { + "title": "Invoice Progress", + "step1": { + "title": "Apply", + "content": "The electronic invoice has been submitted on December 21st" + }, + "step2": { + "title": "Electronic invoice", + "content": "expected to be processed within 1-3 business days." + }, + "step3": { + "title": "Invoice is send", + "content": "we will contact you within 7 business days." + }, + "step4": { + "title": "Finish" + } + }, + "product": { + "title": "Product Category", + "add": "add production", + "month": "month", + "quarter": "quarter" + }, + "detail": { + "title": "Product Procurement Detail", + "budgetExceeded": "Budget Exceeded", + "form": { + "applyNo": "Apply no", + "product": "Name", + "productNo": "No.", + "department": "Department", + "num": "Num", + "createTime": "Create time", + "operation": "Operation", + "manage": "manage", + "delete": "delete" + } + }, + "products": { + "product1": { + "name": "MacBook Pro 2021", + "company": "Apple Inc.", + "size": "13.3 inches", + "cpu": "Apple M1", + "memory": "RAM 16GB", + "info": "Up to 16GB memory · Up to 2TB storage · Battery life up to 18 hours" + }, + "product2": { + "name": "Surface Laptop Go", + "company": "Microsoft Corporation", + "size": "12.4 inches", + "cpu": "Core i7", + "memory": "RAM 16GB", + "info": "Surface regular use, up to 13 hours of battery life, work anytime" + } + } + }, + "detailDeploy": { + "deployTrend": { + "title": "Deploy Trend", + "warning": "Warning", + "thisMonth": "this month", + "thisWeek": "this week", + "lastMonth": "last month", + "thisYear": "this year", + "lastYear": "last year", + "week1": "MON", + "week2": "TUE", + "week3": "WED", + "week4": "THU", + "week5": "FRI", + "week6": "SAT", + "week7": "SUN" + }, + "projectList": { + "title": "Project List", + "dialog": { + "title": "Base Info" + }, + "table": { + "name": "Name", + "admin": "Admin", + "createTime": "Create time", + "operation": "Operation", + "manage": "manage", + "delete": "delete" + } + }, + "baseInfo": { + "clusterName": "Cluster Name", + "clusterId": "Cluster ID", + "status": "Status", + "running": "Running", + "k8sVersion": "K8S Version", + "config": "Config", + "region": "Region", + "guangzhou": "Guangzhou", + "project": "Resource Project", + "defaultProject": "Default Project", + "nodeCount": "Node Count", + "nodeNetwork": "Node Network", + "containerNetwork": "Container Network", + "clusterCredential": "Cluster Credential", + "showCredential": "Show Credential", + "createUpdate": "Create/Update", + "description": "Description" + } + }, + "detailSecondary": { + "read": "Read", + "unread": "Unread", + "all": "All", + "setRead": "set as read", + "setUnread": "set as unread", + "delete": "delete", + "deleteConfirm": "Delete notification", + "deleteTip": "Confirm delete notification: {content}?", + "empty": "Empty" + }, + "notification": { + "contract": "Contract Updates", + "invoice": "Invoice Updates", + "meeting": "Meeting Notification", + "msg1": "Tencent Building First Floor Renovation Project has passed audit!", + "msg2": "Third Quarter Raw Material Procurement Project invoicing successful!", + "msg3": "Meeting 【State Grid Offline Signing】at 2021-01-01 10:00 is about to start, please arrive at Conference Room 1 10 minutes early for check-in!", + "msg4": "First Quarter Raw Material Procurement Project invoicing successful!" + }, + "formBase": { + "title": "Contract Info", + "contractName": "Name", + "contractNamePlaceholder": "Please enter contract name", + "contractStatus": "Status", + "contractNum": "Number", + "contractType": "Type", + "contractTypePlaceholder": "Please enter type", + "contractPayType": "Pay Type", + "contractAmount": "Amount", + "contractAmountPlaceholder": "Please enter amount", + "contractSignDate": "Sign Date", + "contractEffectiveDate": "Effective Date", + "contractEndDate": "End Date", + "company": "Company", + "employee": "Employee", + "pay": "pay", + "receive": "received", + "upload": "upload file", + "uploadFile": "upload contract file", + "uploadTips": "Please upload a PDF file, with a size limit of 60MB.", + "otherInfo": "Other Info", + "remark": "Remark", + "remarkPlaceholder": "please enter remark", + "notaryPublic": "Notary Public", + "fileSizeExceed": "File size cannot exceed 60MB", + "uploadFail": "Upload failed", + "confirm": "confirm", + "cancel": "cancel", + "validation": { + "name": "Please enter contract name", + "type": "Please select contract type", + "payment": "Please select payment type", + "amount": "Please enter amount", + "partyA": "Please select Party A", + "partyB": "Please select Party B", + "signDate": "Please select date", + "startDate": "Please select date", + "endDate": "Please select date" + }, + "typeOptions": { + "typeA": "Type A", + "typeB": "Type B", + "typeC": "Type C" + }, + "partyOptions": { + "companyA": "Company A", + "companyB": "Company B", + "companyC": "Company C" + } + }, + "formStep": { + "validation": { + "name": "Please select contract name", + "type": "Please select type", + "title": "Please enter invoice title", + "taxNum": "Please enter tax number", + "consignee": "Please enter consignee", + "mobileNum": "Please enter mobile number", + "deliveryAddress": "Please select delivery address", + "fullAddress": "Please enter full address" + }, + "options": { + "nameA": "Contract A", + "nameB": "Contract B", + "nameC": "Contract C", + "typeA": "Type A", + "typeB": "Type B", + "typeC": "Type C", + "address1": "Nanshan District, Shenzhen, Guangdong", + "address2": "Haidian District, Beijing", + "address3": "Xuhui District, Shanghai", + "address4": "High-tech Zone, Chengdu, Sichuan", + "address5": "Tianhe District, Guangzhou, Guangdong", + "address6": "High-tech Zone, Xi'an, Shaanxi" + }, + "step1": { + "title": "Submit Application", + "subtitle": "Submitted on December 21st", + "rules": "rules", + "rule1": "1. After applying for an invoice, the electronic invoice will be issued within 1-3 working days. If the qualification review is passed, the VAT special invoice (paper) will be mailed to you within 10 working days after the electronic invoice is issued;", + "rule2": "2. The invoiced amount will be the actual amount you paid;", + "rule3": "3. For any questions, please contact us directly at 13300001111.", + "contractName": "Name", + "contractTips": "Please select a contract name", + "invoiceType": "Invoice type", + "amount": "Amount", + "submit": "Submit Application" + }, + "step2": { + "title": "Electronic Information", + "subtitle": "Contact Customer Service if you have any questions", + "invoiceTitle": "Invoice Title", + "taxNum": "Tax Num", + "address": "Address", + "bank": "Bank", + "bankAccount": "Bank Account", + "email": "Email", + "tel": "Tel", + "invoiceTitlePlaceholder": "please enter invoice title", + "taxNumPlaceholder": "please enter tax num", + "addressPlaceholder": "please enter address", + "bankPlaceholder": "please enter bank", + "bankAccountPlaceholder": "please enter bank account", + "emailPlaceholder": "please enter email", + "telPlaceholder": "please enter tel" + }, + "step3": { + "title": "Invoice Mailed", + "subtitle": "Contact us after the electronic invoice is issued", + "consignee": "Consignee", + "mobileNum": "Mobile Num", + "deliveryAddress": "Address", + "fullAddress": "Full Address" + }, + "step4": { + "title": "Application Completed", + "subtitle": "Contact Customer Service if you have any questions", + "finishTitle": "Application is completed.", + "finishTips": "The electronic invoice is expected to be sent to your email within 1-3 working days. Please be patient for the delivery of the paper invoice.", + "reapply": "reapply", + "check": "check progress" + }, + "preStep": "pre step", + "nextStep": "next step" + }, + "listBase": { + "export": "export", + "create": "create", + "select": "select", + "items": "items", + "contractName": "Name", + "contractStatus": "Status", + "contractNum": "Number", + "contractType": "Type", + "contractPayType": "Pay Type", + "contractAmount": "Amount", + "operation": "Operation", + "detail": "detail", + "delete": "delete", + "pay": "pay", + "receive": "received", + "placeholder": "please enter to search", + "deleteConfirm": "Confirm delete selected contracts?", + "deleteTip": "After deletion, all contract information of {name} will be cleared and cannot be recovered", + "contractStatusEnum": { + "fail": "fail", + "audit": "audit", + "executing": "executing", + "pending": "pending", + "finish": "finish" + } + }, + "listCard": { + "create": "Create Product", + "placeholder": "please enter to search", + "loading": "Loading data...", + "deleteConfirm": "Confirm delete selected products?", + "deleteSuccess": "Deleted successfully", + "deleteTip": "After confirmation, all product information of {name} will be cleared and cannot be recovered", + "productName": "Name", + "productStatus": "Status", + "productDescription": "Description", + "productType": "Type", + "productRemark": "Remark", + "productStatusEnum": { + "off": "off", + "on": "on" + }, + "dialogForm": { + "productNamePlaceholder": "Please enter product name", + "cancel": "Cancel", + "confirm": "Confirm", + "typeGateway": "Gateway", + "typeAI": "Artificial Intelligence", + "typeCVM": "CVM", + "validation": { + "productName": "Please enter product name" + } + } + }, + "listFilter": {}, + "listTree": { + "placeholder": "please enter to search", + "treeData": { + "shenzhen": "Shenzhen HQ", + "beijing": "Beijing HQ", + "shanghai": "Shanghai HQ", + "hunan": "Hunan", + "hubei": "Hubei", + "generalOffice": "General Office", + "marketing": "Marketing", + "technology": "Technology", + "finance": "Finance", + "purchase1": "Purchasing Team 1", + "purchase2": "Purchasing Team 2", + "finance1": "Finance Team 1", + "finance2": "Finance Team 2" + } + }, + "login": { + "loginTitle": "Login in", + "noAccount": "No Account?", + "createAccount": "Create Account", + "remember": "Remember Account", + "forget": "Forget Account", + "signIn": "Sign in", + "existAccount": "Exist Account?", + "register": "Register", + "refresh": "refresh", + "wechatLogin": "Login with WeChat", + "accountLogin": "Login with Account", + "phoneLogin": "Login with Mobile Phone", + "loginSuccess": "Login successful", + "countdown": "Resend after {count} seconds", + "input": { + "account": "please enter account", + "password": "please enter password", + "phone": "please enter phone", + "verification": "please enter verification code" + }, + "required": { + "account": "account is required", + "phone": "phone is required", + "password": "password is required", + "verification": "verification code is require" + }, + "sendVerification": "send", + "register": { + "phonePlaceholder": "Please enter your phone number", + "emailPlaceholder": "Please enter your email", + "passwordPlaceholder": "Please enter your password", + "verifyCodePlaceholder": "Please enter verification code", + "sendVerifyCode": "Send verification code", + "resendCountdown": "Resend in {count} seconds", + "agreeTerms": "I have read and agree to", + "serviceTerms": "TDesign Service Terms", + "privacyStatement": "TDesign Privacy Statement", + "registerBtn": "Register", + "useEmailRegister": "Register with email", + "usePhoneRegister": "Register with phone", + "validation": { + "phone": "Phone number is required", + "email": "Email is required", + "emailFormat": "Please enter a valid email", + "password": "Password is required", + "verifyCode": "Verification code is required", + "agreeTerms": "You must agree to the service terms and privacy statement" + }, + "messages": { + "registerSuccess": "Registration successful" + } + } + }, + "result": { + "403": { + "tips": "sorry, you don not have permission to access this page. Please contact the creator through WeCom", + "back": "Back to homepage" + }, + "404": { + "subtitle": "Sorry, the page is not found", + "back": "Back to homepage" + }, + "500": { + "subtitle": "Sorry, the server is error", + "back": "Back to homepage" + }, + "fail": { + "title": "Create fail", + "subtitle": "Sorry, your project creation has failed", + "back": "Back to homepage", + "modify": "Back to modify" + }, + "success": { + "title": "Project is created", + "subtitle": "Contact the person in charge of distributing the application", + "back": "Back to homepage", + "progress": "Check Progress" + }, + "maintenance": { + "title": "System maintenance", + "subtitle": "Please try again later", + "back": "Back to homepage" + }, + "browserIncompatible": { + "title": "browser is incompatible", + "subtitle": "the browser version you are using is too outdated to open the current webpage.", + "back": "Back to homepage", + "recommend": "TDesign Starter recommend the following browser" + }, + "networkError": { + "title": "Network Error", + "subtitle": "Network error, please try again later", + "back": "Back to homepage", + "reload": "Reload" + } + }, + "user": { + "markDay": "Good afternoon, today marks your 100th day at Tencent", + "greeting": "Hi, Image", + "unit": "(times)", + "account": "My Account", + "organization": "Tencent / Tencent Company / Business Group / Product Department / Operations Center / Merchant Services Group", + "personalInfo": { + "title": "Personal Info", + "position": "Employee of the Hong Kong and Macau Business Expansion team", + "desc": { + "phone": "Phone", + "mobile": "Mobile", + "seat": "Seat", + "email": "Email", + "position": "Position", + "leader": "Leader", + "entity": "Entity", + "joinDay": "Day of join", + "group": "Group" + }, + "tencentGroup": "Tencent Group", + "seniorDesigner": "Senior UI Designer" + }, + "visitData": "Visit Data", + "contentList": "Content List", + "teamMember": "Team Member", + "serviceProduction": "Service Product" + } + } +} diff --git a/src/locales/lang/zh_CN.json b/src/locales/lang/zh_CN.json new file mode 100644 index 0000000..bb78850 --- /dev/null +++ b/src/locales/lang/zh_CN.json @@ -0,0 +1,754 @@ +{ + "lang": "简体中文", + "common": { + "appName": "TDesign Starter", + "copyright": "Copyright © 2021-2025 Tencent. All Rights Reserved", + "conjunction": "和", + "admin": "admin" + }, + "components": { + "isSetup": { + "on": "已启用", + "off": "已停用" + }, + "manage": "管理", + "delete": "删除", + "deleteSuccess": "删除成功", + "copySuccess": "复制成功", + "copyFail": "复制失败", + "submitSuccess": "提交成功", + "commonTable": { + "contractName": "合同名称", + "contractNamePlaceholder": "请输入合同名称", + "contractStatus": "合同状态", + "contractStatusPlaceholder": "请输入合同状态", + "contractNum": "合同编号", + "contractNumPlaceholder": "请输入合同编号", + "contractType": "合同类型", + "contractTypePlaceholder": "请选择合同类型", + "contractPayType": "合同支付类型", + "contractAmount": "合同金额", + "operation": "操作", + "detail": "详情", + "delete": "删除", + "placeholder": "请输入内容搜索", + "contractStatusEnum": { + "fail": "审核失败", + "audit": "待审核", + "executing": "履行中", + "pending": "待履行", + "finish": "已完成" + }, + "contractTypeEnum": { + "main": "主合同", + "sub": "子合同", + "supplement": "补充合同" + }, + "reset": "重置", + "query": "查询" + } + }, + "layout": { + "header": { + "code": "代码仓库", + "help": "帮助文档", + "user": "个人中心", + "signOut": "退出登录", + "setting": "系统设置" + }, + "notice": { + "title": "通知中心", + "clear": "清空", + "setRead": "设为已读", + "empty": "空", + "emptyNotice": "暂无通知", + "viewAll": "查看全部" + }, + "searchPlaceholder": "请输入搜索内容", + "search": { + "placeholder": "输入要搜索内容" + }, + "tagTabs": { + "closeOther": "关闭其他", + "closeLeft": "关闭左侧", + "closeRight": "关闭右侧", + "refresh": "刷新" + }, + "setting": { + "title": "页面配置", + "theme": { + "mode": "主题模式", + "color": "主题色", + "options": { + "light": "明亮", + "dark": "深色", + "auto": "跟随系统" + } + }, + "navigationLayout": "导航布局", + "sideMode": "侧边栏模式", + "splitMenu": "分割菜单(混合模式下有效)", + "fixedSidebar": "固定侧边栏", + "element": { + "title": "元素开关", + "showHeader": "显示顶栏", + "showBreadcrumb": "显示面包屑", + "showFooter": "显示页脚", + "useTagTabs": "展示多标签Tab页", + "menuAutoCollapsed": "菜单自动折叠" + }, + "tips": "请复制后手动修改配置文件: /src/config/style.ts", + "copy": { + "title": "复制配置项", + "success": "复制成功", + "fail": "复制失败" + } + } + }, + "constants": { + "contract": { + "name": "合同名称", + "status": "合同状态", + "num": "合同编号", + "type": "合同类型", + "typePlaceholder": "请输入类型", + "payType": "合同收支类型", + "amount": "合同金额", + "amountPlaceholder": "请输入金额", + "signDate": "合同签订日期", + "effectiveDate": "合同生效日期", + "endDate": "合同结束日期", + "createDate": "合同创建时间", + "company": "甲方", + "employee": "乙方", + "pay": "付款", + "receive": "收款", + "remark": "备注", + "attachment": "附件", + "statusOptions": { + "fail": "审核失败", + "auditPending": "待审核", + "execPending": "待履行", + "executing": "审核成功", + "finish": "已完成" + }, + "typeOptions": { + "main": "主合同", + "sub": "子合同", + "supplement": "补充合同" + } + } + }, + "pages": { + "dashboardBase": { + "title": "概览仪表盘", + "outputOverview": { + "title": "出入库概览", + "subtitle": "(件)", + "export": "导出数据", + "month": { + "input": "本月入库总计(件)", + "output": "本月出库总计(件)" + }, + "since": "自从上周以来" + }, + "rankList": { + "title": "销售订单排名", + "week": "本周", + "month": "近三月", + "info": "详情" + }, + "topPanel": { + "card1": "总收入", + "card2": "总退款", + "card3": "活跃用户(个)", + "card4": "产生订单(个)", + "cardTips": "自从上周以来", + "analysis": { + "title": "统计数据", + "unit": "万元", + "series1": "本月", + "series2": "上月", + "channels": "销售渠道", + "channel1": "线上", + "channel2": "门店", + "channelTips": "销售占比" + } + }, + "saleColumns": { + "index": "排名", + "productName": "客户名称", + "growUp": "较上周", + "count": "订单量", + "operation": "操作" + }, + "buyColumns": { + "index": "排名", + "productName": "供应商名称", + "growUp": "较上周", + "count": "订单量", + "operation": "操作" + }, + "saleTend": { + "item1": "国家电网有限公司", + "item2": "深圳燃气集团股份有限公司", + "item3": "国家烟草专卖局", + "item4": "中国电信集团有限公司", + "item5": "中国移动通信集团有限公司", + "item6": "新余市办公用户采购项目" + }, + "buyTend": { + "item1": "腾讯科技(深圳)有限公司", + "item2": "大润发有限公司", + "item3": "四川海底捞股份有限公司", + "item4": "索尼(中国)有限公司", + "item5": "松下电器(中国)有限公司", + "item6": "新余市办公用户采购项目" + }, + "chart": { + "week1": "周一", + "week2": "周二", + "week3": "周三", + "week4": "周四", + "week5": "周五", + "week6": "周六", + "week7": "周日", + "max": "最大值", + "min": "最小值", + "thisMonth": "本月", + "lastMonth": "上月" + } + }, + "dashboardDetail": { + "topPanel": { + "title": "本月采购申请情况", + "quarter": "环比", + "paneList": { + "totalRequest": "总申请数(次)", + "suppliers": "供应商数量(个)", + "productCategory": "采购商品品类(类)", + "applicant": "申请人数量(人)", + "completionRate": "申请完成率(%)", + "arrivalRate": "到货及时率(%)" + } + }, + "procurement": { + "title": "采购商品申请趋势", + "goods": { + "cup": "杯子", + "tea": "茶叶", + "honey": "蜂蜜", + "flour": "面粉", + "coffeeMachine": "咖啡机", + "massageMachine": "按摩仪" + } + }, + "ssl": "SSL证书", + "sslDescription": "SSL证书又叫服务器证书,腾讯云为您提供证书的一站式服务,包括免费、付费证书的申请、管理及部署", + "satisfaction": { + "title": "采购商品满意度分布", + "export": "导出数据" + }, + "chart": { + "week1": "周一", + "week2": "周二", + "week3": "周三", + "week4": "周四", + "week5": "周五", + "week6": "周六", + "week7": "周日", + "max": "最大值", + "min": "最小值", + "thisMonth": "本月", + "lastMonth": "上月" + } + }, + "detailBase": { + "baseInfo": { + "title": "基本信息" + }, + "sampleData": { + "contractName": "总部办公用品采购项目", + "status": "履行中", + "company": "腾讯科技(深圳)有限公司", + "employee": "欧尚", + "fileName": "总部办公用品采购项目合同.pdf" + }, + "changelog": { + "title": "变更记录", + "step1": { + "title": "上传合同附件", + "subtitle": "这里是提示文字" + }, + "step2": { + "title": "修改合同金额", + "subtitle": "这里是提示文字" + }, + "step3": { + "title": "新建合同", + "desc": "管理员-李川操作" + } + } + }, + "detailCard": { + "baseInfo": { + "title": "基本信息" + }, + "invoice": { + "title": "发票进度", + "step1": { + "title": "申请提交", + "content": "已于12月21日提交" + }, + "step2": { + "title": "电子发票", + "content": "预计1~3个工作日" + }, + "step3": { + "title": "发票已邮寄", + "content": "电子发票开出后7个工作日联系" + }, + "step4": { + "title": "完成" + } + }, + "product": { + "title": "产品目录", + "add": "新增产品", + "month": "月份", + "quarter": "季度" + }, + "detail": { + "title": "产品采购明细", + "budgetExceeded": "超预算", + "form": { + "applyNo": "申请号", + "product": "产品名称", + "productNo": "产品编号", + "operation": "操作", + "department": "申请部门", + "num": "采购数量", + "createTime": "创建时间", + "manage": "管理", + "delete": "删除" + } + }, + "products": { + "product1": { + "name": "MacBook Pro 2021", + "company": "苹果公司(Apple Inc. )", + "size": "13.3 英寸", + "cpu": "Apple M1", + "memory": "RAM 16GB", + "info": "最高可选配 16GB 内存 · 最高可选配 2TB 存储设备 电池续航最长达 18 小时" + }, + "product2": { + "name": "Surface Laptop Go", + "company": "微软(Microsoft Corporation)", + "size": "12.4 英寸", + "cpu": "Core i7", + "memory": "RAM 16GB", + "info": "常规使用 Surface,续航时间最长可达13小时 随时伴您工作" + } + } + }, + "detailDeploy": { + "deployTrend": { + "title": "部署趋势", + "warning": "告警情况", + "thisMonth": "本月", + "thisWeek": "本周", + "lastMonth": "上月", + "thisYear": "今年", + "lastYear": "去年", + "week1": "周一", + "week2": "周二", + "week3": "周三", + "week4": "周四", + "week5": "周五", + "week6": "周六", + "week7": "周日" + }, + "projectList": { + "title": "项目列表", + "dialog": { + "title": "基本信息" + }, + "table": { + "name": "名称", + "admin": "管理员", + "createTime": "创建时间", + "operation": "操作", + "manage": "管理", + "delete": "删除" + } + }, + "baseInfo": { + "clusterName": "集群名", + "clusterId": "集群ID", + "status": "状态", + "running": "运行中", + "k8sVersion": "K8S版本", + "config": "配置", + "region": "所在地域", + "guangzhou": "广州", + "project": "新增资源所属项目", + "defaultProject": "默认项目", + "nodeCount": "节点数量", + "nodeNetwork": "节点网络", + "containerNetwork": "容器网络", + "clusterCredential": "集群凭证", + "showCredential": "显示凭证", + "createUpdate": "创建/更新", + "description": "描述" + } + }, + "detailSecondary": { + "read": "已读通知", + "unread": "未读通知", + "all": "全部通知", + "setRead": "设为已读", + "setUnread": "设为未读", + "delete": "删除通知", + "deleteConfirm": "删除通知", + "deleteTip": "确认删除通知:{content}吗?", + "empty": "暂无通知" + }, + "notification": { + "contract": "合同动态", + "invoice": "票务动态", + "meeting": "会议通知", + "msg1": "腾讯大厦一楼改造施工项目 已通过审核!", + "msg2": "三季度生产原材料采购项目 开票成功!", + "msg3": "2021-01-01 10:00的【国家电网线下签约】会议即将开始,请提前10分钟前往 会议室1 进行签到!", + "msg4": "一季度生产原材料采购项目 开票成功!" + }, + "formBase": { + "title": "合同信息", + "contractName": "合同名称", + "contractNamePlaceholder": "请输入合同名称", + "contractStatus": "合同状态", + "contractNum": "合同编号", + "contractType": "合同类型", + "contractTypePlaceholder": "请输入类型", + "contractPayType": "合同收支类型", + "contractAmount": "合同金额", + "contractAmountPlaceholder": "请输入金额", + "contractSignDate": "合同签订日期", + "contractEffectiveDate": "合同生效日期", + "contractEndDate": "合同结束日期", + "company": "甲方", + "employee": "乙方", + "pay": "付款", + "receive": "收款", + "upload": "上传文件", + "uploadFile": "上传合同", + "uploadTips": "请上传pdf文件,大小在60M以内", + "otherInfo": "其他信息", + "remark": "备注", + "remarkPlaceholder": "请输入备注", + "notaryPublic": "公证人", + "fileSizeExceed": "上传文件不能大于60M", + "uploadFail": "上传失败", + "confirm": "确认提交", + "cancel": "取消", + "validation": { + "name": "请输入合同名称", + "type": "请选择合同类型", + "payment": "请选择合同收付类型", + "amount": "请输入合同金额", + "partyA": "请选择甲方", + "partyB": "请选择乙方", + "signDate": "请选择日期", + "startDate": "请选择日期", + "endDate": "请选择日期" + }, + "typeOptions": { + "typeA": "类型A", + "typeB": "类型B", + "typeC": "类型C" + }, + "partyOptions": { + "companyA": "甲方公司", + "companyB": "乙方公司", + "companyC": "丙方公司" + } + }, + "formStep": { + "validation": { + "name": "请选择合同名称", + "type": "请选择类型", + "title": "请输入发票抬头", + "taxNum": "请输入纳税人识别号", + "consignee": "请输入收货人", + "mobileNum": "请输入手机号码", + "deliveryAddress": "请选择收货地址", + "fullAddress": "请输入详细地址" + }, + "options": { + "nameA": "合同A", + "nameB": "合同B", + "nameC": "合同C", + "typeA": "类型A", + "typeB": "类型B", + "typeC": "类型C", + "address1": "广东省深圳市南山区", + "address2": "北京市海淀区", + "address3": "上海市徐汇区", + "address4": "四川省成都市高新区", + "address5": "广东省广州市天河区", + "address6": "陕西省西安市高新区" + }, + "step1": { + "title": "提交申请", + "subtitle": "已于12月21日提交", + "rules": "发票开具规则", + "rule1": "1、申请开票后,电子发票在1~3个工作日内开具;增值税专用发票(纸质)如资质审核通过,将在电子发票开具后10个工作日内为您寄出;", + "rule2": "2、开票金额为您实际支付金额;", + "rule3": "3、如有疑问请直接联系:13300001111。", + "contractName": "合同名称", + "contractTips": "请选择合同名称", + "invoiceType": "发票类型", + "amount": "开票金额", + "submit": "提交申请" + }, + "step2": { + "title": "电子信息", + "subtitle": "如有疑问联系客服", + "invoiceTitle": "发票抬头", + "taxNum": "纳税人识别号", + "address": "地址", + "bank": "开户行", + "bankAccount": "银行账号", + "email": "邮箱", + "tel": "手机号", + "titlePlaceholder": "请输入电子信息", + "subtitlePlaceholder": "请输入如有疑问联系客服", + "invoiceTitlePlaceholder": "请输入发票抬头", + "taxNumPlaceholder": "请输入纳税人识别号", + "addressPlaceholder": "请输入地址", + "bankPlaceholder": "请输入开户行", + "bankAccountPlaceholder": "请输入银行账号", + "emailPlaceholder": "请输入邮箱", + "telPlaceholder": "请输入手机号" + }, + "step3": { + "title": "发票已邮寄", + "subtitle": "电子发票开出后联系", + "consignee": "收货人", + "mobileNum": "手机号码", + "deliveryAddress": "收货地址", + "fullAddress": "详细地址" + }, + "step4": { + "title": "完成申请", + "subtitle": "如有疑问联系客服", + "finishTitle": "完成开票申请", + "finishTips": "预计1~3个工作日会将电子发票发至邮箱,发票邮寄请耐心等待", + "reapply": "再次申请", + "check": "查看进度" + }, + "preStep": "上一步", + "nextStep": "下一步" + }, + "listBase": { + "export": "导出合同", + "create": "新建合同", + "select": "已选", + "items": "项", + "contractName": "合同名称", + "contractStatus": "合同状态", + "contractNum": "合同编号", + "contractType": "合同类型", + "contractPayType": "合同收支类型", + "contractAmount": "合同金额", + "operation": "操作", + "detail": "详情", + "delete": "删除", + "pay": "付款", + "receive": "收款", + "placeholder": "请输入内容搜索", + "deleteConfirm": "确认删除当前所选合同?", + "deleteTip": "删除后,{name}的所有合同信息将被清空,且无法恢复", + "contractStatusEnum": { + "fail": "审核失败", + "audit": "待审核", + "executing": "履行中", + "pending": "待履行", + "finish": "已完成" + } + }, + "listCard": { + "create": "新建产品", + "placeholder": "请输入内容搜索", + "loading": "加载数据中...", + "deleteConfirm": "确认删除所选产品?", + "deleteSuccess": "删除成功", + "deleteTip": "确认删除后{name}的所有产品信息将被清空, 且无法恢复", + "productName": "产品名称", + "productStatus": "产品状态", + "productDescription": "产品描述", + "productType": "产品类型", + "productRemark": "备注", + "productStatusEnum": { + "off": "停用", + "on": "启用" + }, + "dialogForm": { + "productNamePlaceholder": "请输入产品名称", + "cancel": "取消", + "confirm": "确定", + "typeGateway": "网关", + "typeAI": "人工智能", + "typeCVM": "CVM", + "validation": { + "productName": "请输入产品名称" + } + } + }, + "listFilter": {}, + "listTree": { + "placeholder": "请输入内容进行搜索", + "treeData": { + "shenzhen": "深圳总部", + "beijing": "北京总部", + "shanghai": "上海总部", + "hunan": "湖南", + "hubei": "湖北", + "generalOffice": "总办", + "marketing": "市场部", + "technology": "技术部", + "finance": "财务部", + "purchase1": "采购1组", + "purchase2": "采购2组", + "finance1": "财务1组", + "finance2": "财务2组" + } + }, + "login": { + "loginTitle": "登录到", + "noAccount": "没有账号吗?", + "existAccount": "已有账号?", + "createAccount": "注册新账号", + "remember": "记住账号", + "forget": "忘记账号", + "signIn": "登录", + "register": "注册", + "refresh": "刷新", + "wechatLogin": "使用微信扫一扫登录", + "accountLogin": "使用账号登录", + "phoneLogin": "使用手机号登录", + "loginSuccess": "登录成功", + "countdown": "{count}秒后可重发", + "input": { + "account": "请输入账号", + "password": "请输入登录密码", + "phone": "请输入手机号", + "verification": "请输入验证码" + }, + "required": { + "account": "账号必填", + "phone": "手机号必填", + "password": "密码必填", + "verification": "验证码必填" + }, + "sendVerification": "发送验证码", + "register": { + "phonePlaceholder": "请输入您的手机号", + "emailPlaceholder": "请输入您的邮箱", + "passwordPlaceholder": "请输入登录密码", + "verifyCodePlaceholder": "请输入验证码", + "sendVerifyCode": "发送验证码", + "resendCountdown": "{count}秒后可重发", + "agreeTerms": "我已阅读并同意", + "serviceTerms": "TDesign服务协议", + "privacyStatement": "TDesign 隐私声明", + "registerBtn": "注册", + "useEmailRegister": "使用邮箱注册", + "usePhoneRegister": "使用手机号注册", + "validation": { + "phone": "手机号必填", + "email": "邮箱必填", + "emailFormat": "请输入正确的邮箱", + "password": "密码必填", + "verifyCode": "验证码必填", + "agreeTerms": "请同意TDesign服务协议和TDesign 隐私声明" + }, + "messages": { + "registerSuccess": "注册成功" + } + } + }, + "result": { + "403": { + "tips": "抱歉,您无权限访问此页面,企业微信联系创建者", + "back": "返回首页" + }, + "404": { + "subtitle": "抱歉,您访问的页面不存在", + "back": "返回首页" + }, + "500": { + "subtitle": "抱歉,服务器出错啦", + "back": "返回首页" + }, + "fail": { + "title": "创建失败", + "subtitle": "抱歉,您的项目创建失败,企业微信联系检查创建者权限,或返回修改。", + "back": "回到首页", + "modify": "返回修改" + }, + "success": { + "title": "项目已创建成功", + "subtitle": "可以联系负责人分发应用", + "back": "回到首页", + "progress": "查看进度" + }, + "maintenance": { + "title": "系统维护中", + "subtitle": "系统维护中,请稍后再试", + "back": "回到首页" + }, + "browserIncompatible": { + "title": "浏览器不兼容", + "subtitle": "抱歉,您正在使用的浏览器版本过低,无法打开当前网页。", + "back": "回到首页", + "recommend": "TDesign Starter 推荐以下主流浏览器" + }, + "networkError": { + "title": "网络异常", + "subtitle": "网络异常, 请稍后重试", + "back": "回到首页", + "reload": "重新加载" + } + }, + "user": { + "markDay": "下午好,今天是你加入鹅厂的第 100 天", + "greeting": "Hi,Image", + "unit": "(次)", + "account": "My Account", + "organization": "腾讯/腾讯公司/某事业群/某产品部/某运营中心/商户服务组", + "personalInfo": { + "title": "个人信息", + "position": "港澳业务拓展组员工 直客销售 ", + "desc": { + "phone": "座机", + "mobile": "手机", + "seat": "座位", + "email": "邮箱", + "position": "职位", + "leader": "上级", + "entity": "主体", + "joinDay": "入职时间", + "group": "所属团队" + }, + "tencentGroup": "腾讯集团", + "seniorDesigner": "高级 UI 设计师" + }, + "visitData": "首页访问数据", + "contentList": "内容列表", + "teamMember": "团队成员", + "serviceProduction": "服务产品" + } + } +} diff --git a/src/locales/useLocale.ts b/src/locales/useLocale.ts new file mode 100644 index 0000000..39c106f --- /dev/null +++ b/src/locales/useLocale.ts @@ -0,0 +1,32 @@ +import type { GlobalConfigProvider } from 'tdesign-vue-next'; +import { computed } from 'vue'; + +import type { SupportedLocale } from '@/locales/index'; +import { i18n, localeConfigKey, supportedLocales } from '@/locales/index'; +import { useNotificationStore } from '@/store/modules/notification'; + +export function useLocale() { + const locale = computed({ + get: () => i18n.global.locale.value, + set: (val: string) => { + i18n.global.locale.value = val; + }, + }); + const changeLocale = (lang: string) => { + const validLang = supportedLocales.includes(lang as SupportedLocale) ? (lang as SupportedLocale) : 'zh_CN'; + localStorage.setItem(localeConfigKey, validLang); + locale.value = validLang; + // 刷新持久化的翻译数据 + useNotificationStore().refreshMsgData(); + }; + + const getComponentsLocale = computed(() => { + return (i18n.global.getLocaleMessage(locale.value) as Record).componentsLocale as GlobalConfigProvider; + }); + + return { + changeLocale, + getComponentsLocale, + locale, + }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..d483faf --- /dev/null +++ b/src/main.ts @@ -0,0 +1,21 @@ +/* eslint-disable simple-import-sort/imports */ +import { createApp } from 'vue'; +import TDesign from 'tdesign-vue-next'; + +import App from './App.vue'; +import router from './router'; +import { store } from './store'; +import i18n from './locales'; + +import 'tdesign-vue-next/es/style/index.css'; +import '@/style/index.less'; +import './permission'; + +const app = createApp(App); + +app.use(TDesign); +app.use(store); +app.use(router); +app.use(i18n); + +app.mount('#app'); diff --git a/src/pages/dashboard/base/components/MiddleChart.vue b/src/pages/dashboard/base/components/MiddleChart.vue new file mode 100644 index 0000000..0e2290c --- /dev/null +++ b/src/pages/dashboard/base/components/MiddleChart.vue @@ -0,0 +1,222 @@ + + + diff --git a/src/pages/dashboard/base/components/OutputOverview.vue b/src/pages/dashboard/base/components/OutputOverview.vue new file mode 100644 index 0000000..7a58dd8 --- /dev/null +++ b/src/pages/dashboard/base/components/OutputOverview.vue @@ -0,0 +1,218 @@ + + + diff --git a/src/pages/dashboard/base/components/RankList.vue b/src/pages/dashboard/base/components/RankList.vue new file mode 100644 index 0000000..a1c5a2f --- /dev/null +++ b/src/pages/dashboard/base/components/RankList.vue @@ -0,0 +1,194 @@ + + + diff --git a/src/pages/dashboard/base/components/TopPanel.vue b/src/pages/dashboard/base/components/TopPanel.vue new file mode 100644 index 0000000..d21137b --- /dev/null +++ b/src/pages/dashboard/base/components/TopPanel.vue @@ -0,0 +1,289 @@ + + + diff --git a/src/pages/dashboard/base/constants.ts b/src/pages/dashboard/base/constants.ts new file mode 100644 index 0000000..9380371 --- /dev/null +++ b/src/pages/dashboard/base/constants.ts @@ -0,0 +1,84 @@ +interface TendItem { + growUp?: number; + productName: string; + count: number; + date: string; +} + +export const SALE_TEND_LIST: Array = [ + { + growUp: 1, + productName: 'pages.dashboardBase.saleTend.item1', + count: 7059, + date: '2021-09-01', + }, + { + growUp: -1, + productName: 'pages.dashboardBase.saleTend.item2', + count: 6437, + date: '2021-09-01', + }, + { + growUp: 4, + productName: 'pages.dashboardBase.saleTend.item3', + count: 4221, + date: '2021-09-01', + }, + { + growUp: 3, + productName: 'pages.dashboardBase.saleTend.item4', + count: 3317, + date: '2021-09-01', + }, + { + growUp: -3, + productName: 'pages.dashboardBase.saleTend.item5', + count: 3015, + date: '2021-09-01', + }, + { + growUp: -3, + productName: 'pages.dashboardBase.saleTend.item6', + count: 2015, + date: '2021-09-12', + }, +]; + +export const BUY_TEND_LIST: Array = [ + { + growUp: 1, + productName: 'pages.dashboardBase.buyTend.item1', + count: 3015, + date: '2021-09-01', + }, + { + growUp: -1, + productName: 'pages.dashboardBase.buyTend.item2', + count: 2015, + date: '2021-09-01', + }, + { + growUp: 6, + productName: 'pages.dashboardBase.buyTend.item3', + count: 1815, + date: '2021-09-11', + }, + { + growUp: -3, + productName: 'pages.dashboardBase.buyTend.item4', + count: 1015, + date: '2021-09-21', + }, + { + growUp: -4, + productName: 'pages.dashboardBase.buyTend.item5', + count: 445, + date: '2021-09-19', + }, + { + growUp: -3, + productName: 'pages.dashboardBase.buyTend.item6', + count: 2015, + date: '2021-09-12', + }, +]; diff --git a/src/pages/dashboard/base/index.ts b/src/pages/dashboard/base/index.ts new file mode 100644 index 0000000..927fdee --- /dev/null +++ b/src/pages/dashboard/base/index.ts @@ -0,0 +1,407 @@ +import dayjs from 'dayjs'; +import type { EChartsOption } from 'echarts'; + +import type { TChartColor } from '@/config/color'; +import { t } from '@/locales/index'; +import { getRandomArray } from '@/utils/charts'; +import { getChartListColor } from '@/utils/color'; + +/** 首页 dashboard 折线图 */ +export function constructInitDashboardDataset(type: string) { + const dateArray: Array = [ + t('pages.dashboardBase.chart.week1'), + t('pages.dashboardBase.chart.week2'), + t('pages.dashboardBase.chart.week3'), + t('pages.dashboardBase.chart.week4'), + t('pages.dashboardBase.chart.week5'), + t('pages.dashboardBase.chart.week6'), + t('pages.dashboardBase.chart.week7'), + ]; + + const datasetAxis = { + xAxis: { + type: 'category', + show: false, + data: dateArray, + }, + yAxis: { + show: false, + type: 'value', + }, + grid: { + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + }; + + if (type === 'line') { + const lineDataset = { + ...datasetAxis, + color: ['#fff'], + series: [ + { + data: [150, 230, 224, 218, 135, 147, 260], + type, + showSymbol: true, + symbol: 'circle', + symbolSize: 0, + markPoint: { + data: [ + { type: 'max', name: t('pages.dashboardBase.chart.max') }, + { type: 'min', name: t('pages.dashboardBase.chart.min') }, + ], + }, + lineStyle: { + width: 2, + }, + }, + ], + }; + return lineDataset; + } + const barDataset = { + ...datasetAxis, + color: getChartListColor(), + series: [ + { + data: [ + 100, + 130, + 184, + 218, + { + value: 135, + itemStyle: { + opacity: 0.2, + }, + }, + { + value: 118, + itemStyle: { + opacity: 0.2, + }, + }, + { + value: 60, + itemStyle: { + opacity: 0.2, + }, + }, + ], + type, + barWidth: 9, + }, + ], + }; + return barDataset; +} + +/** 柱状图数据源 */ +export function constructInitDataset({ + dateTime = [], + placeholderColor, + borderColor, +}: { dateTime: Array } & TChartColor) { + const divideNum = 10; + const timeArray = []; + const inArray = []; + const outArray = []; + for (let i = 0; i < divideNum; i++) { + if (dateTime.length > 0) { + const dateAbsTime: number = (new Date(dateTime[1]).getTime() - new Date(dateTime[0]).getTime()) / divideNum; + const enhandTime: number = new Date(dateTime[0]).getTime() + dateAbsTime * i; + timeArray.push(dayjs(enhandTime).format('YYYY-MM-DD')); + } else { + timeArray.push( + dayjs() + .subtract(divideNum - i, 'day') + .format('YYYY-MM-DD'), + ); + } + + inArray.push(getRandomArray().toString()); + outArray.push(getRandomArray().toString()); + } + + const dataset = { + color: getChartListColor(), + tooltip: { + trigger: 'item', + }, + xAxis: { + type: 'category', + data: timeArray, + axisLabel: { + color: placeholderColor, + }, + axisLine: { + lineStyle: { + color: getChartListColor()[1], + width: 1, + }, + }, + }, + yAxis: { + type: 'value', + axisLabel: { + color: placeholderColor, + }, + splitLine: { + lineStyle: { + color: borderColor, + }, + }, + }, + grid: { + top: '5%', + left: '25px', + right: 0, + bottom: '60px', + }, + legend: { + icon: 'rect', + itemWidth: 12, + itemHeight: 4, + itemGap: 48, + textStyle: { + fontSize: 12, + color: placeholderColor, + }, + left: 'center', + bottom: '0', + orient: 'horizontal', + data: [t('pages.dashboardBase.chart.thisMonth'), t('pages.dashboardBase.chart.lastMonth')], + }, + series: [ + { + name: t('pages.dashboardBase.chart.thisMonth'), + data: outArray, + type: 'bar', + }, + { + name: t('pages.dashboardBase.chart.lastMonth'), + data: inArray, + type: 'bar', + }, + ], + }; + + return dataset; +} + +/** + * 线性图表数据源 + * + * @export + * @param {Array} [dateTime] + * @returns {*} dataSet + */ +export function getLineChartDataSet({ + dateTime = [], + placeholderColor, + borderColor, +}: { dateTime?: Array } & TChartColor) { + const divideNum = 10; + const timeArray = []; + const inArray = []; + const outArray = []; + for (let i = 0; i < divideNum; i++) { + if (dateTime.length > 0) { + const dateAbsTime: number = (new Date(dateTime[1]).getTime() - new Date(dateTime[0]).getTime()) / divideNum; + const enhandTime: number = new Date(dateTime[0]).getTime() + dateAbsTime * i; + // console.log('dateAbsTime..', dateAbsTime, enhandTime); + timeArray.push(dayjs(enhandTime).format('MM-DD')); + } else { + timeArray.push( + dayjs() + .subtract(divideNum - i, 'day') + .format('MM-DD'), + ); + } + + inArray.push(getRandomArray().toString()); + outArray.push(getRandomArray().toString()); + } + + const dataSet = { + color: getChartListColor(), + tooltip: { + trigger: 'item', + }, + grid: { + left: '0', + right: '20px', + top: '5px', + bottom: '36px', + containLabel: true, + }, + legend: { + left: 'center', + bottom: '0', + orient: 'horizontal', // legend 横向布局。 + data: [t('pages.dashboardBase.chart.thisMonth'), t('pages.dashboardBase.chart.lastMonth')], + textStyle: { + fontSize: 12, + color: placeholderColor, + }, + }, + xAxis: { + type: 'category', + data: timeArray, + boundaryGap: false, + axisLabel: { + color: placeholderColor, + }, + axisLine: { + lineStyle: { + width: 1, + }, + }, + }, + yAxis: { + type: 'value', + axisLabel: { + color: placeholderColor, + }, + splitLine: { + lineStyle: { + color: borderColor, + }, + }, + }, + series: [ + { + name: t('pages.dashboardBase.chart.thisMonth'), + data: outArray, + type: 'line', + smooth: false, + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + itemStyle: { + borderColor, + borderWidth: 1, + }, + areaStyle: { + opacity: 0.1, + }, + }, + { + name: t('pages.dashboardBase.chart.lastMonth'), + data: inArray, + type: 'line', + smooth: false, + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + ], + }; + return dataSet; +} + +/** + * 获取饼图数据 + * + * @export + * @param {number} [radius] + * @returns {*} dataSet + */ +export function getPieChartDataSet({ + radius = 42, + textColor, + placeholderColor, + containerColor, +}: { radius?: number } & Record): EChartsOption { + return { + color: getChartListColor(), + tooltip: { + show: false, + trigger: 'axis', + position: undefined, + }, + grid: { + top: '0', + right: '0', + }, + legend: { + selectedMode: false, + itemWidth: 12, + itemHeight: 4, + textStyle: { + fontSize: 12, + color: placeholderColor, + }, + left: 'center', + bottom: '0', + orient: 'horizontal', // legend 横向布局。 + }, + series: [ + { + name: t('pages.dashboardBase.topPanel.analysis.channels'), + type: 'pie', + radius: ['48%', '60%'], + avoidLabelOverlap: true, + selectedMode: true, + silent: true, + itemStyle: { + borderColor: containerColor, + borderWidth: 1, + }, + label: { + show: true, + position: 'center', + formatter: ['{value|{d}%}', '{name|{b}}'].join('\n'), + rich: { + value: { + color: textColor, + fontSize: 28, + fontWeight: 'normal', + lineHeight: 46, + }, + name: { + color: '#909399', + fontSize: 12, + lineHeight: 14, + }, + }, + }, + emphasis: { + scale: true, + label: { + show: false, + rich: { + value: { + color: textColor, + fontSize: 28, + fontWeight: 'normal', + lineHeight: 46, + }, + name: { + color: '#909399', + fontSize: 14, + lineHeight: 14, + }, + }, + }, + }, + labelLine: { + show: false, + }, + data: [ + { + value: 1048, + name: t('pages.dashboardBase.topPanel.analysis.channel1'), + }, + { value: radius * 7, name: t('pages.dashboardBase.topPanel.analysis.channel2') }, + ], + }, + ], + }; +} diff --git a/src/pages/dashboard/base/index.vue b/src/pages/dashboard/base/index.vue new file mode 100644 index 0000000..ce2a60a --- /dev/null +++ b/src/pages/dashboard/base/index.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/pages/dashboard/detail/constants.ts b/src/pages/dashboard/detail/constants.ts new file mode 100644 index 0000000..cbaeacf --- /dev/null +++ b/src/pages/dashboard/detail/constants.ts @@ -0,0 +1,52 @@ +import { t } from '@/locales'; + +export const getPaneListData = () => [ + { + title: t('pages.dashboardDetail.topPanel.paneList.totalRequest'), + number: '1126', + upTrend: '10%', + }, + { + title: t('pages.dashboardDetail.topPanel.paneList.suppliers'), + number: '13', + downTrend: '13%', + }, + { + title: t('pages.dashboardDetail.topPanel.paneList.productCategory'), + number: '4', + upTrend: '10%', + }, + { + title: t('pages.dashboardDetail.topPanel.paneList.applicant'), + number: 90, + downTrend: '44%', + leftType: 'icon-file-paste', + }, + { + title: t('pages.dashboardDetail.topPanel.paneList.completionRate'), + number: 80.5, + upTrend: '70%', + }, + { + title: t('pages.dashboardDetail.topPanel.paneList.arrivalRate'), + number: 78, + upTrend: '16%', + }, +]; + +export const getProductList = () => [ + { + description: t('pages.dashboardDetail.sslDescription'), + index: 1, + isSetup: true, + name: t('pages.dashboardDetail.ssl'), + type: 4, + }, + { + description: t('pages.dashboardDetail.sslDescription'), + index: 1, + isSetup: true, + name: t('pages.dashboardDetail.ssl'), + type: 4, + }, +]; diff --git a/src/pages/dashboard/detail/index.ts b/src/pages/dashboard/detail/index.ts new file mode 100644 index 0000000..080ae5b --- /dev/null +++ b/src/pages/dashboard/detail/index.ts @@ -0,0 +1,272 @@ +import dayjs from 'dayjs'; + +import type { TChartColor } from '@/config/color'; +import { t } from '@/locales'; +import { getDateArray, getRandomArray } from '@/utils/charts'; +import { getChartListColor } from '@/utils/color'; + +/** + * 散点图数据 + * + * @export + * @returns {*} dataSet + */ +export function getScatterDataSet({ + dateTime = [], + placeholderColor, + borderColor, +}: { dateTime?: Array } & TChartColor) { + const divideNum = 40; + const timeArray = []; + const inArray = []; + const outArray = []; + for (let i = 0; i < divideNum; i++) { + // const [timeArray, inArray, outArray] = dataset; + if (dateTime.length > 0) { + const dateAbsTime: number = (new Date(dateTime[1]).getTime() - new Date(dateTime[0]).getTime()) / divideNum; + const endTime: number = new Date(dateTime[0]).getTime() + dateAbsTime * i; + timeArray.push(dayjs(endTime).format('MM-DD')); + } else { + timeArray.push( + dayjs() + .subtract(divideNum - i, 'day') + .format('MM-DD'), + ); + } + + inArray.push(getRandomArray().toString()); + outArray.push(getRandomArray().toString()); + } + + return { + color: getChartListColor(), + xAxis: { + data: timeArray, + axisLabel: { + color: placeholderColor, + }, + splitLine: { show: false }, + axisLine: { + lineStyle: { + color: borderColor, + width: 1, + }, + }, + }, + yAxis: { + type: 'value', + axisLabel: { + color: placeholderColor, + }, + nameTextStyle: { + padding: [0, 0, 0, 60], + }, + axisTick: { + show: false, + axisLine: { + show: false, + }, + }, + axisLine: { + show: false, + }, + splitLine: { + lineStyle: { + color: borderColor, + }, + }, + }, + tooltip: { + trigger: 'item', + }, + grid: { + top: '5px', + left: '25px', + right: '5px', + bottom: '60px', + }, + legend: { + left: 'center', + bottom: '0', + orient: 'horizontal', // legend 横向布局。 + data: [ + t(`pages.dashboardDetail.procurement.goods.massageMachine`), + t(`pages.dashboardDetail.procurement.goods.coffeeMachine`), + ], + itemHeight: 8, + itemWidth: 8, + textStyle: { + fontSize: 12, + color: placeholderColor, + }, + }, + series: [ + { + name: t(`pages.dashboardDetail.procurement.goods.massageMachine`), + symbolSize: 10, + data: outArray.reverse(), + type: 'scatter', + }, + { + name: t(`pages.dashboardDetail.procurement.goods.coffeeMachine`), + symbolSize: 10, + data: inArray.concat(inArray.reverse()), + type: 'scatter', + }, + ], + }; +} + +/** 折线图数据 */ +export function getFolderLineDataSet({ + dateTime = [], + placeholderColor, + borderColor, +}: { dateTime?: Array } & TChartColor) { + let dateArray = []; + for (let i = 1; i < 7; i++) { + dateArray.push(t(`pages.dashboardDetail.chart.week${i}`)); + } + if (dateTime.length > 0) { + const divideNum = 7; + dateArray = getDateArray(dateTime, divideNum); + } + return { + color: getChartListColor(), + grid: { + top: '5%', + right: '10px', + left: '30px', + bottom: '60px', + }, + legend: { + left: 'center', + bottom: '0', + orient: 'horizontal', // legend 横向布局。 + data: [ + t(`pages.dashboardDetail.procurement.goods.cup`), + t(`pages.dashboardDetail.procurement.goods.tea`), + t(`pages.dashboardDetail.procurement.goods.honey`), + t(`pages.dashboardDetail.procurement.goods.flour`), + ], + textStyle: { + fontSize: 12, + color: placeholderColor, + }, + }, + xAxis: { + type: 'category', + data: dateArray, + boundaryGap: false, + axisLabel: { + color: placeholderColor, + }, + axisLine: { + lineStyle: { + color: borderColor, + width: 1, + }, + }, + }, + yAxis: { + type: 'value', + axisLabel: { + color: placeholderColor, + }, + splitLine: { + lineStyle: { + color: borderColor, + }, + }, + }, + tooltip: { + trigger: 'item', + }, + series: [ + { + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + name: t(`pages.dashboardDetail.procurement.goods.cup`), + stack: '总量', + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + { + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + name: t(`pages.dashboardDetail.procurement.goods.tea`), + stack: '总量', + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + { + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + name: t(`pages.dashboardDetail.procurement.goods.honey`), + stack: '总量', + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + { + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + name: t(`pages.dashboardDetail.procurement.goods.flour`), + stack: '总量', + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + ], + }; +} diff --git a/src/pages/dashboard/detail/index.vue b/src/pages/dashboard/detail/index.vue new file mode 100644 index 0000000..f780a9c --- /dev/null +++ b/src/pages/dashboard/detail/index.vue @@ -0,0 +1,277 @@ + + + diff --git a/src/pages/detail/advanced/components/Product.vue b/src/pages/detail/advanced/components/Product.vue new file mode 100644 index 0000000..3300a4e --- /dev/null +++ b/src/pages/detail/advanced/components/Product.vue @@ -0,0 +1,169 @@ + + + diff --git a/src/pages/detail/advanced/constants.ts b/src/pages/detail/advanced/constants.ts new file mode 100644 index 0000000..a4b2108 --- /dev/null +++ b/src/pages/detail/advanced/constants.ts @@ -0,0 +1,103 @@ +import { t } from '@/locales'; + +export const getBaseInfoData = () => [ + { + name: t('constants.contract.name'), + value: t('pages.detailBase.sampleData.contractName'), + type: null, + }, + { + name: t('constants.contract.status'), + value: t('pages.detailBase.sampleData.status'), + type: { + key: 'contractStatus', + value: 'inProgress', + }, + }, + { + name: t('constants.contract.num'), + value: 'BH00010', + type: null, + }, + { + name: t('constants.contract.type'), + value: t('constants.contract.typeOptions.main'), + type: null, + }, + { + name: t('constants.contract.payType'), + value: t('constants.contract.pay'), + type: null, + }, + { + name: t('constants.contract.amount'), + value: '¥ 5,000,000', + type: null, + }, + { + name: t('constants.contract.company'), + value: t('pages.detailBase.sampleData.company'), + type: null, + }, + { + name: t('constants.contract.employee'), + value: t('pages.detailBase.sampleData.employee'), + type: null, + }, + { + name: t('constants.contract.signDate'), + value: '2020-12-20', + type: null, + }, + { + name: t('constants.contract.effectiveDate'), + value: '2021-01-20', + type: null, + }, + { + name: t('constants.contract.endDate'), + value: '2022-12-20', + type: null, + }, + { + name: t('constants.contract.attachment'), + value: t('pages.detailBase.sampleData.fileName'), + type: { + key: 'contractAnnex', + value: 'pdf', + }, + }, + { + name: t('constants.contract.remark'), + value: '--', + type: null, + }, + { + name: t('constants.contract.createDate'), + value: '2020-12-22 10:00:00', + type: null, + }, +]; + +export const getProductList = () => [ + { + name: t('pages.detailCard.products.product1.name'), + subtitle: t('pages.detailCard.products.product1.company'), + size: t('pages.detailCard.products.product1.size'), + cpu: t('pages.detailCard.products.product1.cpu'), + memory: t('pages.detailCard.products.product1.memory'), + info: t('pages.detailCard.products.product1.info'), + use: 1420, + stock: 1500, + }, + { + name: t('pages.detailCard.products.product2.name'), + subtitle: t('pages.detailCard.products.product2.company'), + size: t('pages.detailCard.products.product2.size'), + cpu: t('pages.detailCard.products.product2.cpu'), + memory: t('pages.detailCard.products.product2.memory'), + info: t('pages.detailCard.products.product2.info'), + use: 120, + stock: 2000, + }, +]; diff --git a/src/pages/detail/advanced/index.less b/src/pages/detail/advanced/index.less new file mode 100644 index 0000000..588fdeb --- /dev/null +++ b/src/pages/detail/advanced/index.less @@ -0,0 +1,91 @@ +@import '../base/index.less'; + +.detail-advanced { + :deep(.t-card) { + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + } + + :deep(.t-card__header) { + padding: 0; + margin-bottom: var(--td-comp-margin-m); + } + + :deep(.t-card__body) { + padding: 0; + } + + :deep(.t-card__title) { + font: var(--td-font-title-large); + font-weight: 400; + } + + .advanced-card { + margin-top: 0 !important; + + .card-title-default { + margin-bottom: var(--td-comp-margin-m); + } + } +} + +.container-base-margin-top { + :deep(.t-card__body) { + margin-top: var(--td-comp-margin-xxxl); + } + + :deep(.t-text-ellipsis) { + width: auto; + } +} + +.product-block-container { + .t-col-xl-4 + .t-col-xl-4 { + @media (max-width: @screen-lg-max) { + .operator-gap { + margin: var(--td-comp-margin-l) 0 0 0; + } + } + } + + .product-add { + width: 100%; + height: 240px; + display: flex; + place-items: center center; + border: dashed 1px var(--td-component-border); + border-radius: var(--td-radius-medium); + + .product-sub-icon { + background: var(--td-brand-color-light); + color: var(--td-brand-color); + font-size: var(--td-comp-size-xxxl); + padding: calc(var(--td-comp-size-xxxl) - var(--td-comp-size-xl)); + border-radius: 100%; + } + + .product-sub { + font: var(--td-font-body-medium); + color: var(--td-text-color-secondary); + margin: 0 auto; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + + svg { + rect { + fill: var(--td-brand-color-light); + } + + path { + fill: var(--td-brand-color); + } + } + } + + span { + padding-top: var(--td-comp-margin-xxl); + } + } +} diff --git a/src/pages/detail/advanced/index.vue b/src/pages/detail/advanced/index.vue new file mode 100644 index 0000000..d9c2da8 --- /dev/null +++ b/src/pages/detail/advanced/index.vue @@ -0,0 +1,263 @@ + + + diff --git a/src/pages/detail/base/index.less b/src/pages/detail/base/index.less new file mode 100644 index 0000000..420e27f --- /dev/null +++ b/src/pages/detail/base/index.less @@ -0,0 +1,28 @@ +.t-descriptions { + span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-left: var(--td-comp-margin-xxl); + } + + i { + display: inline-block; + width: 8px; + height: 8px; + border-radius: var(--td-radius-circle); + background: var(--td-success-color); + } + + .inProgress { + color: var(--td-success-color); + } + + .pdf { + color: var(--td-brand-color); + + &:hover { + cursor: pointer; + } + } +} diff --git a/src/pages/detail/base/index.vue b/src/pages/detail/base/index.vue new file mode 100644 index 0000000..00125f0 --- /dev/null +++ b/src/pages/detail/base/index.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/pages/detail/deploy/constants.ts b/src/pages/detail/deploy/constants.ts new file mode 100644 index 0000000..71ed190 --- /dev/null +++ b/src/pages/detail/deploy/constants.ts @@ -0,0 +1,84 @@ +import { t } from '@/locales'; + +export const getBaseInfoData = () => [ + { + name: t('pages.detailDeploy.baseInfo.clusterName'), + value: 'helloworld', + type: null, + }, + { + name: t('pages.detailDeploy.baseInfo.clusterId'), + value: 'cls - 2ntelvxw', + type: { + key: 'color', + value: 'blue', + }, + }, + { + name: t('pages.detailDeploy.baseInfo.status'), + value: t('pages.detailDeploy.baseInfo.running'), + type: { + key: 'color', + value: 'green', + }, + }, + { + name: t('pages.detailDeploy.baseInfo.k8sVersion'), + value: '1.7.8', + type: null, + }, + { + name: t('pages.detailDeploy.baseInfo.config'), + value: '6.73 核 10.30 GB', + type: null, + }, + { + name: t('pages.detailDeploy.baseInfo.region'), + value: t('pages.detailDeploy.baseInfo.guangzhou'), + type: null, + }, + { + name: t('pages.detailDeploy.baseInfo.project'), + value: t('pages.detailDeploy.baseInfo.defaultProject'), + type: null, + }, + { + name: t('pages.detailDeploy.baseInfo.nodeCount'), + value: '4 个', + type: null, + }, + { + name: t('pages.detailDeploy.baseInfo.nodeNetwork'), + value: 'vpc - 5frmkm1x', + type: { + key: 'color', + value: 'blue', + }, + }, + { + name: t('pages.detailDeploy.baseInfo.containerNetwork'), + value: '172.16.0.0 / 16', + type: null, + }, + { + name: t('pages.detailDeploy.baseInfo.clusterCredential'), + value: t('pages.detailDeploy.baseInfo.showCredential'), + type: { + key: 'color', + value: 'blue', + }, + }, + { + name: t('pages.detailDeploy.baseInfo.createUpdate'), + value: '2018-05-31 22:11:44 2018-05-31 22:11:44', + type: { + key: 'contractAnnex', + value: 'pdf', + }, + }, + { + name: t('pages.detailDeploy.baseInfo.description'), + value: 'istio_test', + type: null, + }, +]; diff --git a/src/pages/detail/deploy/index.ts b/src/pages/detail/deploy/index.ts new file mode 100644 index 0000000..41825cc --- /dev/null +++ b/src/pages/detail/deploy/index.ts @@ -0,0 +1,219 @@ +import type { TChartColor } from '@/config/color'; +import { t } from '@/locales'; +import { getDateArray, getRandomArray } from '@/utils/charts'; +import { getChartListColor } from '@/utils/color'; + +/** 平滑图数据 */ +export function getSmoothLineDataSet({ + dateTime = [], + placeholderColor, + borderColor, +}: { dateTime?: Array } & TChartColor) { + let dateArray: Array = ['00:00', '02:00', '04:00', '06:00']; + if (dateTime.length > 0) { + const divideNum = 7; + dateArray = getDateArray(dateTime, divideNum); + } + + return { + color: getChartListColor(), + tooltip: { + trigger: 'item', + }, + grid: { + top: '10px', + left: '0', + right: '20px', + bottom: '36px', + containLabel: true, + }, + xAxis: { + type: 'category', + data: dateArray, + boundaryGap: false, + axisLabel: { + color: placeholderColor, + }, + axisLine: { + lineStyle: { + color: borderColor, + width: 1, + }, + }, + }, + yAxis: { + type: 'value', + axisLabel: { + color: placeholderColor, + }, + splitLine: { + lineStyle: { + color: borderColor, + }, + }, + }, + legend: { + data: [t('pages.detailDeploy.deployTrend.thisMonth'), t('pages.detailDeploy.deployTrend.lastMonth')], + icon: 'circle', + bottom: '0', + itemGap: 48, + itemHeight: 8, + itemWidth: 8, + textStyle: { + fontSize: 12, + color: placeholderColor, + }, + }, + series: [ + { + name: t('pages.detailDeploy.deployTrend.lastMonth'), + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + smooth: true, + color: getChartListColor()[0], + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + areaStyle: { + opacity: 0.1, + }, + }, + { + name: t('pages.detailDeploy.deployTrend.thisMonth'), + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + smooth: true, + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + color: getChartListColor()[1], + }, + ], + }; +} + +export const lastYearList: Array = [100, 120, 140, 160, 180, 200, 210]; + +/** + * 柱状图数据结构 + * + * @export + * @param {boolean} [isMonth] + * @returns {*} dataSet + */ +export function get2ColBarChartDataSet({ + isMonth = false, + placeholderColor, + borderColor, +}: { isMonth?: boolean } & TChartColor) { + let lastYearListCopy = lastYearList.concat([]); + let thisYearListCopy = lastYearList.concat([]); + + if (isMonth) { + lastYearListCopy = lastYearListCopy.reverse(); + thisYearListCopy = thisYearListCopy.reverse(); + } + + const data = []; + for (let i = 1; i < 7; i++) { + data.push(t(`pages.detailDeploy.deployTrend.week${i}`)); + } + + return { + color: getChartListColor(), + tooltip: { + trigger: 'item', + }, + grid: { + top: '10px', + left: '0', + right: '0', + bottom: '36px', + containLabel: true, + }, + xAxis: [ + { + type: 'category', + data, + axisTick: { + alignWithLabel: true, + }, + axisLabel: { + color: placeholderColor, + }, + axisLine: { + lineStyle: { + color: borderColor, + width: 1, + }, + }, + }, + ], + yAxis: [ + { + type: 'value', + axisLabel: { + color: placeholderColor, + }, + splitLine: { + lineStyle: { + color: borderColor, + }, + }, + }, + ], + legend: { + data: [t('pages.detailDeploy.deployTrend.lastYear'), t('pages.detailDeploy.deployTrend.thisYear')], + bottom: '0', + icon: 'rect', + itemGap: 48, + itemHeight: 4, + itemWidth: 12, + textStyle: { + fontSize: 12, + color: placeholderColor, + }, + }, + series: [ + { + name: t('pages.detailDeploy.deployTrend.lastYear'), + type: 'bar', + barWidth: '30%', + data: lastYearListCopy, + itemStyle: { + color: '#BCC4D0', + }, + }, + { + name: t('pages.detailDeploy.deployTrend.thisYear'), + type: 'bar', + barWidth: '30%', + data: thisYearListCopy, + itemStyle: { + color: (params: { value: number }) => { + if (params.value >= 200) { + return getChartListColor()[1]; + } + return getChartListColor()[0]; + }, + }, + }, + ], + }; +} diff --git a/src/pages/detail/deploy/index.vue b/src/pages/detail/deploy/index.vue new file mode 100644 index 0000000..fef3dbc --- /dev/null +++ b/src/pages/detail/deploy/index.vue @@ -0,0 +1,264 @@ + + + diff --git a/src/pages/detail/secondary/index.less b/src/pages/detail/secondary/index.less new file mode 100644 index 0000000..cff9b98 --- /dev/null +++ b/src/pages/detail/secondary/index.less @@ -0,0 +1,76 @@ +.secondary-notification { + background-color: var(--td-bg-color-container); + border-radius: var(--td-radius-medium); + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + + .t-tabs__content { + padding-top: 0; + } +} + +.secondary-msg-list { + height: 70vh; + + .t-list-item { + cursor: pointer; + padding: var(--td-comp-paddingTB-l) 0; + transition: 0.2s linear; + + &:hover { + background-color: var(--td-bg-color-container-hover); + + .msg-date { + display: none; + } + + .msg-action { + display: flex; + align-items: center; + + &-icon { + display: flex; + align-items: center; + } + } + } + + :deep(.t-tag) { + margin-right: var(--td-comp-margin-l); + } + + .t-tag.t-size-s { + margin-right: var(--td-comp-margin-s); + margin-left: 0; + } + } + + .content { + font: var(--td-font-body-medium); + color: var(--td-text-color-placeholder); + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .unread { + color: var(--td-text-color-primary); + } + + .msg-action { + display: none; + margin-right: var(--td-comp-margin-xxl); + transition: 0.2s linear; + + .set-read-icon { + margin-right: var(--td-comp-margin-l); + } + } + + &__empty-list { + min-height: 443px; + padding-top: 170px; + text-align: center; + color: var(--td-text-color-primary); + } +} diff --git a/src/pages/detail/secondary/index.vue b/src/pages/detail/secondary/index.vue new file mode 100644 index 0000000..fa0ae4f --- /dev/null +++ b/src/pages/detail/secondary/index.vue @@ -0,0 +1,125 @@ + + + diff --git a/src/pages/form/base/constants.ts b/src/pages/form/base/constants.ts new file mode 100644 index 0000000..0be0f28 --- /dev/null +++ b/src/pages/form/base/constants.ts @@ -0,0 +1,47 @@ +import type { FormRule, UploadFile } from 'tdesign-vue-next'; + +import { t } from '@/locales'; + +export const getFormRules = (): Record => ({ + name: [{ required: true, message: t('pages.formBase.validation.name'), type: 'error' }], + type: [{ required: true, message: t('pages.formBase.validation.type'), type: 'error' }], + payment: [{ required: true, message: t('pages.formBase.validation.payment'), type: 'error' }], + amount: [{ required: true, message: t('pages.formBase.validation.amount'), type: 'error' }], + partyA: [{ required: true, message: t('pages.formBase.validation.partyA'), type: 'error' }], + partyB: [{ required: true, message: t('pages.formBase.validation.partyB'), type: 'error' }], + signDate: [{ required: true, message: t('pages.formBase.validation.signDate'), type: 'error' }], + startDate: [{ required: true, message: t('pages.formBase.validation.startDate'), type: 'error' }], + endDate: [{ required: true, message: t('pages.formBase.validation.endDate'), type: 'error' }], +}); + +export const INITIAL_DATA = { + name: '', + type: '', + partyA: '', + partyB: '', + signDate: '', + startDate: '', + endDate: '', + payment: '1', + amount: 0, + comment: '', + files: [] as Array, +}; + +export const getTypeOptions = () => [ + { label: t('pages.formBase.typeOptions.typeA'), value: '1' }, + { label: t('pages.formBase.typeOptions.typeB'), value: '2' }, + { label: t('pages.formBase.typeOptions.typeC'), value: '3' }, +]; + +export const getPartyAOptions = () => [ + { label: t('pages.formBase.partyOptions.companyA'), value: '1' }, + { label: t('pages.formBase.partyOptions.companyB'), value: '2' }, + { label: t('pages.formBase.partyOptions.companyC'), value: '3' }, +]; + +export const getPartyBOptions = () => [ + { label: t('pages.formBase.partyOptions.companyA'), value: '1' }, + { label: t('pages.formBase.partyOptions.companyB'), value: '2' }, + { label: t('pages.formBase.partyOptions.companyC'), value: '3' }, +]; diff --git a/src/pages/form/base/index.less b/src/pages/form/base/index.less new file mode 100644 index 0000000..ba71225 --- /dev/null +++ b/src/pages/form/base/index.less @@ -0,0 +1,61 @@ +.form-basic-container { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--td-bg-color-container); + border-radius: var(--td-radius-medium) var(--td-radius-medium) 0 0; + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl) 80px var(--td-comp-paddingLR-xxl); + + @media (max-width: @screen-sm-max) { + padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl) 80px var(--td-comp-paddingLR-xl); + + .form-basic-container-title { + margin: 0 0 var(--td-comp-margin-xxxl) 0; + } + } + + .form-basic-item { + width: 676px; + + .form-basic-container-title { + font: var(--td-font-title-large); + font-weight: 400; + color: var(--td-text-color-primary); + margin: var(--td-comp-margin-xxl) 0 var(--td-comp-margin-xl) 0; + } + + .form-title-gap { + margin: calc(var(--td-comp-margin-xxl) * 2) 0 var(--td-comp-margin-xl) 0; + } + } +} + +.form-submit-container { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding-top: var(--td-comp-paddingLR-xl); + padding-bottom: var(--td-comp-paddingLR-xl); + background-color: var(--td-bg-color-secondarycontainer); + border-bottom-left-radius: var(--td-radius-medium); + border-bottom-right-radius: var(--td-radius-medium); + border-top: 1px solid var(--td-component-stroke); + + .form-submit-sub { + width: 676px; + display: flex; + align-items: center; + justify-content: space-between; + + .form-submit-left { + .form-submit-upload-span { + font-size: 14px; + line-height: 22px; + color: var(--td-text-color-placeholder); + padding-top: 8px; + display: inline-block; + } + } + } +} diff --git a/src/pages/form/base/index.vue b/src/pages/form/base/index.vue new file mode 100644 index 0000000..3399907 --- /dev/null +++ b/src/pages/form/base/index.vue @@ -0,0 +1,210 @@ + + + diff --git a/src/pages/form/step/constants.ts b/src/pages/form/step/constants.ts new file mode 100644 index 0000000..3da6d04 --- /dev/null +++ b/src/pages/form/step/constants.ts @@ -0,0 +1,57 @@ +import type { FormRule } from 'tdesign-vue-next'; + +import { t } from '@/locales'; + +export const getFormRules = (): Record => ({ + name: [{ required: true, message: t('pages.formStep.validation.name'), type: 'error' }], + type: [{ required: true, message: t('pages.formStep.validation.type'), type: 'error' }], + title: [{ required: true, message: t('pages.formStep.validation.title'), type: 'error' }], + taxNum: [{ required: true, message: t('pages.formStep.validation.taxNum'), type: 'error' }], + consignee: [{ required: true, message: t('pages.formStep.validation.consignee'), type: 'error' }], + mobileNum: [{ required: true, message: t('pages.formStep.validation.mobileNum'), type: 'error' }], + deliveryAddress: [{ required: true, message: t('pages.formStep.validation.deliveryAddress'), type: 'error' }], + fullAddress: [{ required: true, message: t('pages.formStep.validation.fullAddress'), type: 'error' }], +}); + +export const getNameOptions = () => [ + { label: t('pages.formStep.options.nameA'), value: '1' }, + { label: t('pages.formStep.options.nameB'), value: '2' }, + { label: t('pages.formStep.options.nameC'), value: '3' }, +]; + +export const getTypeOptions = () => [ + { label: t('pages.formStep.options.typeA'), value: '1' }, + { label: t('pages.formStep.options.typeB'), value: '2' }, + { label: t('pages.formStep.options.typeC'), value: '3' }, +]; + +export const getAddressOptions = () => [ + { label: t('pages.formStep.options.address1'), value: '1' }, + { label: t('pages.formStep.options.address2'), value: '2' }, + { label: t('pages.formStep.options.address3'), value: '3' }, + { label: t('pages.formStep.options.address4'), value: '4' }, + { label: t('pages.formStep.options.address5'), value: '5' }, + { label: t('pages.formStep.options.address6'), value: '6' }, +]; + +export const INITIAL_DATA1 = { + name: '', + type: '', +}; + +export const INITIAL_DATA2 = { + title: '', + taxNum: '', + address: '', + bank: '', + bankAccount: '', + email: '', + tel: '', +}; + +export const INITIAL_DATA3 = { + consignee: '', + mobileNum: '', + deliveryAddress: '', + fullAddress: '', +}; diff --git a/src/pages/form/step/index.less b/src/pages/form/step/index.less new file mode 100644 index 0000000..2eec9f4 --- /dev/null +++ b/src/pages/form/step/index.less @@ -0,0 +1,19 @@ +.form-step-container { + background-color: var(--td-bg-color-container); + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + border-radius: var(--td-radius-medium); + + .t-card { + :deep(.t-card__body) { + padding: 0; + } + } +} + +.rule-tips { + margin-top: var(--td-comp-margin-xxxl); +} + +.step-form { + margin-top: var(--td-comp-margin-xxxl); +} diff --git a/src/pages/form/step/index.vue b/src/pages/form/step/index.vue new file mode 100644 index 0000000..c8c1faf --- /dev/null +++ b/src/pages/form/step/index.vue @@ -0,0 +1,227 @@ + + + diff --git a/src/pages/list/base/index.vue b/src/pages/list/base/index.vue new file mode 100644 index 0000000..5e7eb31 --- /dev/null +++ b/src/pages/list/base/index.vue @@ -0,0 +1,281 @@ + + + diff --git a/src/pages/list/card/components/DialogForm.vue b/src/pages/list/card/components/DialogForm.vue new file mode 100644 index 0000000..1236ab3 --- /dev/null +++ b/src/pages/list/card/components/DialogForm.vue @@ -0,0 +1,126 @@ + + diff --git a/src/pages/list/card/index.vue b/src/pages/list/card/index.vue new file mode 100644 index 0000000..d57686e --- /dev/null +++ b/src/pages/list/card/index.vue @@ -0,0 +1,202 @@ + + + diff --git a/src/pages/list/filter/index.vue b/src/pages/list/filter/index.vue new file mode 100644 index 0000000..4a0f031 --- /dev/null +++ b/src/pages/list/filter/index.vue @@ -0,0 +1,10 @@ + + diff --git a/src/pages/list/tree/constants.ts b/src/pages/list/tree/constants.ts new file mode 100644 index 0000000..0211582 --- /dev/null +++ b/src/pages/list/tree/constants.ts @@ -0,0 +1,88 @@ +import { t } from '@/locales'; + +export const getTreeData = () => [ + { + label: t('pages.listTree.treeData.shenzhen'), + value: 0, + children: [ + { + label: t('pages.listTree.treeData.generalOffice'), + value: '0-0', + }, + { + label: t('pages.listTree.treeData.marketing'), + value: '0-1', + children: [ + { + label: t('pages.listTree.treeData.purchase1'), + value: '0-1-0', + }, + { + label: t('pages.listTree.treeData.purchase2'), + value: '0-1-1', + }, + ], + }, + { + label: t('pages.listTree.treeData.technology'), + value: '0-2', + }, + ], + }, + { + label: t('pages.listTree.treeData.beijing'), + value: 1, + children: [ + { + label: t('pages.listTree.treeData.generalOffice'), + value: '1-0', + }, + { + label: t('pages.listTree.treeData.marketing'), + value: '1-1', + children: [ + { + label: t('pages.listTree.treeData.purchase1'), + value: '1-1-0', + }, + { + label: t('pages.listTree.treeData.purchase2'), + value: '1-1-1', + }, + ], + }, + ], + }, + { + label: t('pages.listTree.treeData.shanghai'), + value: 2, + children: [ + { + label: t('pages.listTree.treeData.marketing'), + value: '2-0', + }, + { + label: t('pages.listTree.treeData.finance'), + value: '2-1', + children: [ + { + label: t('pages.listTree.treeData.finance1'), + value: '2-1-0', + }, + { + label: t('pages.listTree.treeData.finance2'), + value: '2-1-1', + }, + ], + }, + ], + }, + { + label: t('pages.listTree.treeData.hunan'), + value: 3, + }, + { + label: t('pages.listTree.treeData.hubei'), + value: 4, + }, +]; diff --git a/src/pages/list/tree/index.vue b/src/pages/list/tree/index.vue new file mode 100644 index 0000000..205ff0c --- /dev/null +++ b/src/pages/list/tree/index.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/pages/login/components/Header.vue b/src/pages/login/components/Header.vue new file mode 100644 index 0000000..35ea32c --- /dev/null +++ b/src/pages/login/components/Header.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/pages/login/components/Login.vue b/src/pages/login/components/Login.vue new file mode 100644 index 0000000..2fbe930 --- /dev/null +++ b/src/pages/login/components/Login.vue @@ -0,0 +1,154 @@ + + + diff --git a/src/pages/login/components/Register.vue b/src/pages/login/components/Register.vue new file mode 100644 index 0000000..1b700a5 --- /dev/null +++ b/src/pages/login/components/Register.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/pages/login/index.less b/src/pages/login/index.less new file mode 100644 index 0000000..b978dd5 --- /dev/null +++ b/src/pages/login/index.less @@ -0,0 +1,194 @@ +.light { + &.login-wrapper { + background-color: white; + background-image: url('@/assets/assets-login-bg-white.png'); + } +} + +.dark { + &.login-wrapper { + background-color: var(--td-bg-color-page); + background-image: url('@/assets/assets-login-bg-black.png'); + } +} + +.login-wrapper { + height: 100vh; + display: flex; + flex-direction: column; + background-size: cover; + background-position: 100%; + position: relative; +} + +.login-container { + position: absolute; + top: 22%; + left: 5%; + min-height: 500px; +} + +.title-container { + .title { + font: var(--td-font-headline-large); + color: var(--td-text-color-primary); + margin-top: var(--td-comp-margin-xs); + + &.margin-no { + margin-top: 0; + } + } + + .sub-title { + margin-top: var(--td-comp-margin-xxl); + + .tip { + display: inline-block; + margin-right: var(--td-comp-margin-s); + font: var(--td-font-body-medium); + + &:first-child { + color: var(--td-text-color-secondary); + } + + &:last-child { + color: var(--td-text-color-primary); + cursor: pointer; + } + } + } +} + +.item-container { + width: 400px; + margin-top: var(--td-comp-margin-xxxxl); + + &.login-qrcode { + .tip-container { + margin-bottom: var(--td-comp-margin-l); + font: var(--td-font-body-medium); + display: flex; + align-items: flex-start; + + .tip { + color: var(--td-text-color-primary); + margin-right: var(--td-comp-margin-s); + } + + .refresh { + display: flex; + align-items: center; + color: var(--td-brand-color); + + .t-icon { + font-size: var(--td-comp-size-xxxs); + margin-left: var(--td-comp-margin-xs); + } + + &:hover { + cursor: pointer; + } + } + } + + .bottom-container { + margin-top: 32px; + } + } + + &.login-phone { + .bottom-container { + margin-top: 66px; + } + } + + .check-container { + display: flex; + align-items: center; + + &.remember-pwd { + margin-bottom: var(--td-comp-margin-l); + justify-content: space-between; + } + + span { + color: var(--td-brand-color); + + &:hover { + cursor: pointer; + } + } + } + + .verification-code { + display: flex; + align-items: center; + + :deep(.t-form__controls) { + width: 100%; + + button { + flex-shrink: 0; + margin-left: var(--td-comp-margin-l); + width: 128px; + } + } + } + + .btn-container { + margin-top: var(--td-comp-margin-xxxxl); + } +} + +.switch-container { + margin-top: var(--td-comp-margin-xxl); + + .tip { + font: var(--td-font-body-medium); + color: var(--td-brand-color); + cursor: pointer; + display: inline-flex; + align-items: center; + margin-right: var(--td-comp-margin-l); + + &:last-child { + &::after { + display: none; + } + } + + &::after { + content: ''; + display: block; + width: 1px; + height: 12px; + background: var(--td-component-stroke); + margin-left: var(--td-comp-margin-l); + } + } +} + +.check-container { + font: var(--td-font-body-medium); + color: var(--td-text-color-secondary); + + .tip { + float: right; + font: var(--td-font-body-medium); + color: var(--td-brand-color); + } +} + +.copyright { + font: var(--td-font-body-medium); + position: absolute; + left: 5%; + bottom: 64px; + color: var(--td-text-color-secondary); +} + +@media screen and (height <= 700px) { + .copyright { + display: none; + } +} diff --git a/src/pages/login/index.vue b/src/pages/login/index.vue new file mode 100644 index 0000000..9c23e7c --- /dev/null +++ b/src/pages/login/index.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/pages/result/403/index.vue b/src/pages/result/403/index.vue new file mode 100644 index 0000000..e57d973 --- /dev/null +++ b/src/pages/result/403/index.vue @@ -0,0 +1,17 @@ + + diff --git a/src/pages/result/404/index.vue b/src/pages/result/404/index.vue new file mode 100644 index 0000000..e16060e --- /dev/null +++ b/src/pages/result/404/index.vue @@ -0,0 +1,17 @@ + + diff --git a/src/pages/result/500/index.vue b/src/pages/result/500/index.vue new file mode 100644 index 0000000..7fe8641 --- /dev/null +++ b/src/pages/result/500/index.vue @@ -0,0 +1,17 @@ + + diff --git a/src/pages/result/browser-incompatible/index.vue b/src/pages/result/browser-incompatible/index.vue new file mode 100644 index 0000000..9961d04 --- /dev/null +++ b/src/pages/result/browser-incompatible/index.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/pages/result/fail/index.vue b/src/pages/result/fail/index.vue new file mode 100644 index 0000000..6f14011 --- /dev/null +++ b/src/pages/result/fail/index.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/pages/result/maintenance/index.vue b/src/pages/result/maintenance/index.vue new file mode 100644 index 0000000..7224acb --- /dev/null +++ b/src/pages/result/maintenance/index.vue @@ -0,0 +1,17 @@ + + diff --git a/src/pages/result/network-error/index.vue b/src/pages/result/network-error/index.vue new file mode 100644 index 0000000..572d12d --- /dev/null +++ b/src/pages/result/network-error/index.vue @@ -0,0 +1,20 @@ + + diff --git a/src/pages/result/success/index.vue b/src/pages/result/success/index.vue new file mode 100644 index 0000000..6d1a7f6 --- /dev/null +++ b/src/pages/result/success/index.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/pages/user/constants.ts b/src/pages/user/constants.ts new file mode 100644 index 0000000..9b8b2be --- /dev/null +++ b/src/pages/user/constants.ts @@ -0,0 +1,72 @@ +import { t } from '@/locales'; + +export interface UserInfoListType { + title: string; + content: string; + span?: number; +} + +export const getUserInfoList = (): Array => [ + { + title: 'pages.user.personalInfo.desc.mobile', + content: '+86 13923734567', + }, + { + title: 'pages.user.personalInfo.desc.phone', + content: '734567', + }, + { + title: 'pages.user.personalInfo.desc.email', + content: 'Account@qq.com', + }, + { + title: 'pages.user.personalInfo.desc.seat', + content: 'T32F 012', + }, + { + title: 'pages.user.personalInfo.desc.entity', + content: t('pages.user.personalInfo.tencentGroup'), + }, + { + title: 'pages.user.personalInfo.desc.leader', + content: 'Michael Wang', + }, + { + title: 'pages.user.personalInfo.desc.position', + content: t('pages.user.personalInfo.seniorDesigner'), + }, + { + title: 'pages.user.personalInfo.desc.joinDay', + content: '2021-07-01', + }, + { + title: 'pages.user.personalInfo.desc.group', + content: t('pages.user.organization'), + span: 6, + }, +]; + +export const getTeamMembers = () => [ + { + avatar: 'https://avatars.githubusercontent.com/Wen1kang', + title: 'Lovellzhong', + description: `${t('pages.user.teamMember')} 1`, + }, + { + avatar: 'https://avatars.githubusercontent.com/pengYYYYY', + title: 'Jiajingwang', + description: `${t('pages.user.teamMember')} 2`, + }, + { + avatar: 'https://avatars.githubusercontent.com/u/24469546?s=96&v=4', + title: 'cruisezhang', + description: `${t('pages.user.teamMember')} 3`, + }, + { + avatar: 'https://avatars.githubusercontent.com/u/88708072?s=96&v=4', + title: 'Lovellzhang', + description: `${t('pages.user.teamMember')} 4`, + }, +]; + +export const PRODUCT_LIST = ['a', 'b', 'c', 'd']; diff --git a/src/pages/user/index.less b/src/pages/user/index.less new file mode 100644 index 0000000..33bf30e --- /dev/null +++ b/src/pages/user/index.less @@ -0,0 +1,191 @@ +:deep(.t-card__title) { + font: var(--td-font-title-large); + font-weight: 400; +} + +.user-left-greeting { + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + font: var(--td-font-title-large); + background: var(--td-bg-color-container); + color: var(--td-text-color-primary); + text-align: left; + border-radius: var(--td-radius-medium); + display: flex; + justify-content: space-between; + align-items: center; + + .regular { + margin-left: var(--td-comp-margin-xl); + font: var(--td-font-body-medium); + } + + .logo { + width: 168px; + } +} + +.user-info-list { + margin-top: var(--td-comp-margin-l); + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + + .content { + width: 90%; + } + + :deep(.t-card__header) { + padding: 0; + } + + :deep(.t-card__body) { + padding: 0; + } + + .contract { + &-title { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + margin: var(--td-comp-margin-xxxl) 0 var(--td-comp-margin-l); + font: var(--td-font-body-medium); + color: var(--td-text-color-placeholder); + } + + &-detail { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + font: var(--td-font-body-medium); + color: var(--td-text-color-primary); + } + } + + .contract:last-child { + margin-bottom: 0; + } +} + +.user-intro { + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + background: var(--td-brand-color); + border-radius: var(--td-radius-medium); + color: var(--td-text-color-primary); + + :deep(.t-card__body) { + padding: 0; + } + + .name { + font: var(--td-font-title-large); + margin-top: var(--td-comp-margin-xxxl); + color: var(--td-text-color-anti); + } + + .position { + font: var(--td-font-body-medium); + margin-top: var(--td-comp-margin-s); + color: var(--td-text-color-anti); + } + + .user-info { + line-height: 24px; + font-size: 14px; + color: var(--td-text-color-primary); + + .hiredate, + .del, + .mail { + display: flex; + } + + .t-icon { + height: 24px; + margin-right: 8px; + } + + .del { + margin: 16px 0; + } + } +} + +.product-container { + margin-top: var(--td-comp-margin-l); + border-radius: var(--td-radius-medium); + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + + :deep(.t-card__header) { + padding: 0; + margin-bottom: var(--td-comp-margin-m); + } + + :deep(.t-card__body) { + padding: 0; + } + + .content { + width: 100%; + margin: var(--td-comp-margin-xxxl) 0 0; + } + + .logo { + width: var(--td-comp-size-xxl); + } +} + +.content-container { + margin-top: var(--td-comp-margin-l); + background: var(--td-bg-color-container); + border-radius: var(--td-radius-medium); + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + + :deep(.t-card__header) { + padding: 0; + } + + :deep(.t-card__body) { + padding: 0; + } + + .card-padding-no { + margin-top: var(--td-comp-margin-xxxl); + + :deep(.t-card__body) { + margin-top: var(--td-comp-margin-xxl); + } + } +} + +.user-team { + margin-top: var(--td-comp-margin-l); + padding: var(--td-comp-paddingTB-xxl) var(--td-comp-paddingLR-xxl); + + :deep(.t-card__header) { + padding: 0; + margin-bottom: var(--td-comp-margin-m); + } + + :deep(.t-card__body) { + padding: 0; + } + + .t-list-item { + margin-top: var(--td-comp-margin-xxl); + padding: 0; + + :deep(.t-list-item__meta-avatar) { + height: var(--td-comp-size-xxl); + width: var(--td-comp-size-xxl); + margin-right: var(--td-comp-margin-xxl); + } + + :deep(.t-list-item__meta-title) { + margin: 0 0 var(--td-comp-margin-xs); + } + + :deep(.t-list-item__meta-description) { + display: inline-block; + color: var(--td-text-color-placeholder); + font: var(--td-font-body-medium); + } + } +} diff --git a/src/pages/user/index.ts b/src/pages/user/index.ts new file mode 100644 index 0000000..94e3aca --- /dev/null +++ b/src/pages/user/index.ts @@ -0,0 +1,163 @@ +import type { TChartColor } from '@/config/color'; +import { t } from '@/locales'; +import { getDateArray, getRandomArray } from '@/utils/charts'; +import { getChartListColor } from '@/utils/color'; + +/** 折线图数据 */ +export function getFolderLineDataSet({ + dateTime = [], + placeholderColor, + borderColor, +}: { dateTime?: Array } & TChartColor) { + let dateArray: Array = [ + t('pages.dashboardBase.chart.week1'), + t('pages.dashboardBase.chart.week2'), + t('pages.dashboardBase.chart.week3'), + t('pages.dashboardBase.chart.week4'), + t('pages.dashboardBase.chart.week5'), + t('pages.dashboardBase.chart.week6'), + t('pages.dashboardBase.chart.week7'), + ]; + if (dateTime.length > 0) { + const divideNum = 7; + dateArray = getDateArray(dateTime, divideNum); + } + return { + color: getChartListColor(), + grid: { + top: '5%', + right: '10px', + left: '30px', + bottom: '60px', + }, + legend: { + left: 'center', + bottom: '0', + orient: 'horizontal', // legend 横向布局。 + data: [ + t('pages.dashboardDetail.procurement.goods.cup'), + t('pages.dashboardDetail.procurement.goods.tea'), + t('pages.dashboardDetail.procurement.goods.honey'), + t('pages.dashboardDetail.procurement.goods.flour'), + ], + textStyle: { + fontSize: 12, + color: placeholderColor, + }, + }, + xAxis: { + type: 'category', + data: dateArray, + boundaryGap: false, + axisLabel: { + color: placeholderColor, + }, + axisLine: { + lineStyle: { + color: borderColor, + width: 1, + }, + }, + }, + yAxis: { + type: 'value', + axisLabel: { + color: placeholderColor, + }, + splitLine: { + lineStyle: { + color: borderColor, + }, + }, + }, + tooltip: { + trigger: 'item', + }, + series: [ + { + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + name: t('pages.dashboardDetail.procurement.goods.cup'), + stack: '总量', + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + { + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + name: t('pages.dashboardDetail.procurement.goods.tea'), + stack: '总量', + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + { + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + name: t('pages.dashboardDetail.procurement.goods.honey'), + stack: '总量', + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + { + showSymbol: true, + symbol: 'circle', + symbolSize: 8, + name: t('pages.dashboardDetail.procurement.goods.flour'), + stack: '总量', + data: [ + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + getRandomArray(), + ], + type: 'line', + itemStyle: { + borderColor, + borderWidth: 1, + }, + }, + ], + }; +} diff --git a/src/pages/user/index.vue b/src/pages/user/index.vue new file mode 100644 index 0000000..12dbb0b --- /dev/null +++ b/src/pages/user/index.vue @@ -0,0 +1,194 @@ + + + diff --git a/src/permission.ts b/src/permission.ts new file mode 100644 index 0000000..7e10bdd --- /dev/null +++ b/src/permission.ts @@ -0,0 +1,85 @@ +import 'nprogress/nprogress.css'; // progress bar style + +import NProgress from 'nprogress'; // progress bar +import { MessagePlugin } from 'tdesign-vue-next'; +import type { RouteRecordRaw } from 'vue-router'; + +import router from '@/router'; +import { getPermissionStore, useUserStore } from '@/store'; +import { normalizePath } from '@/utils/route'; +import { PAGE_NOT_FOUND_ROUTE } from '@/utils/route/constant'; + +NProgress.configure({ showSpinner: false }); + +router.beforeEach(async (to, from, next) => { + NProgress.start(); + + const permissionStore = getPermissionStore(); + const { whiteListRouters } = permissionStore; + + const userStore = useUserStore(); + + if (userStore.token) { + if (normalizePath(to.path) === '/login') { + next(); + return; + } + try { + await userStore.getUserInfo(); + + const { asyncRoutes } = permissionStore; + + if (asyncRoutes && asyncRoutes.length === 0) { + const routeList = await permissionStore.buildAsyncRoutes(); + routeList.forEach((item: RouteRecordRaw) => { + router.addRoute(item); + }); + + if (to.name === PAGE_NOT_FOUND_ROUTE.name) { + // 动态添加路由后,此处应当重定向到fullPath,否则会加载404页面内容 + next({ path: to.fullPath, replace: true, query: to.query }); + } else { + // 直接基于 to 重新导航以加载动态路由; + // 若用 from.query.redirect 手动拼接 path+query,当 redirect 带参(如 /dashboard/base?tab=1)时 + // to.path !== redirect 恒成立,且显式 query 会覆盖 path 内嵌参数,导致参数丢失,故统一用 to。 + next({ ...to, replace: true }); + return; + } + } + if (router.hasRoute(to.name!)) { + next(); + } else { + next(`/`); + } + } catch (error) { + MessagePlugin.error((error as Error).message); + next({ + path: '/login', + query: { redirect: to.fullPath }, + }); + NProgress.done(); + } + } else { + /* white list router */ + if (whiteListRouters.includes(normalizePath(to.path))) { + next(); + } else { + next({ + path: '/login', + query: { redirect: to.fullPath }, + }); + } + NProgress.done(); + } +}); + +router.afterEach((to) => { + if (normalizePath(to.path) === '/login') { + const userStore = useUserStore(); + const permissionStore = getPermissionStore(); + + userStore.logout(); + permissionStore.restoreRoutes(); + } + NProgress.done(); +}); diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..d8a23a7 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,98 @@ +import isObject from 'lodash/isObject'; +import uniq from 'lodash/uniq'; +import type { RouteRecordRaw } from 'vue-router'; +import { createRouter, createWebHistory } from 'vue-router'; + +import { normalizePath } from '@/utils/route'; + +const env = import.meta.env.MODE || 'development'; + +// 导入homepage相关固定路由 +const homepageModules = import.meta.glob('./modules/**/homepage.ts', { eager: true }); + +// 导入modules非homepage相关固定路由 +const fixedModules = import.meta.glob(['./modules/**/*.ts', '!./modules/**/homepage.ts'], { eager: true }); + +// 其他固定路由 +const defaultRouterList: Array = [ + { + path: '/login', + name: 'login', + component: () => import('@/pages/login/index.vue'), + }, + { + path: '/', + redirect: '/dashboard/base', + }, +]; +// 存放固定路由 +export const homepageRouterList: Array = mapModuleRouterList(homepageModules); +export const fixedRouterList: Array = mapModuleRouterList(fixedModules); + +export const allRoutes = [...homepageRouterList, ...fixedRouterList, ...defaultRouterList]; + +// 固定路由模块转换为路由 +export function mapModuleRouterList(modules: Record): Array { + const routerList: Array = []; + Object.keys(modules).forEach((key) => { + const routeModule = modules[key]; + if (isObject(routeModule) && 'default' in routeModule) { + const route = routeModule.default; + const routes = Array.isArray(route) ? [...route] : [route]; + routerList.push(...routes); + } + }); + return routerList; +} + +/** + * + * @deprecated 未使用 + */ +export const getRoutesExpanded = () => { + const expandedRoutes: Array = []; + + fixedRouterList.forEach((item) => { + if (item.meta && item.meta.expanded) { + expandedRoutes.push(item.path); + } + if (item.children && item.children.length > 0) { + item.children + .filter((child) => child.meta && child.meta.expanded) + .forEach((child: RouteRecordRaw) => { + expandedRoutes.push(item.path); + expandedRoutes.push(`${item.path}/${child.path}`); + }); + } + }); + return uniq(expandedRoutes); +}; + +export const getActive = (maxLevel = 3): string => { + // 非组件内调用必须通过Router实例获取当前路由 + const route = router.currentRoute.value; + + if (!route.path) { + return ''; + } + + return normalizePath(route.path) + .split('/') + .filter((_item: string, index: number) => index <= maxLevel && index > 0) + .map((item: string) => `/${item}`) + .join(''); +}; + +const router = createRouter({ + history: createWebHistory(env === 'site' ? '/starter/vue-next/' : import.meta.env.VITE_BASE_URL), + routes: allRoutes, + scrollBehavior() { + return { + el: '#app', + top: 0, + behavior: 'smooth', + }; + }, +}); + +export default router; diff --git a/src/router/modules/homepage.ts b/src/router/modules/homepage.ts new file mode 100644 index 0000000..b12ab4c --- /dev/null +++ b/src/router/modules/homepage.ts @@ -0,0 +1,58 @@ +import { DashboardIcon } from 'tdesign-icons-vue-next'; +import { shallowRef } from 'vue'; +import type { RouteRecordRaw } from 'vue-router'; + +import { LAYOUT } from '@/utils/route/constant'; + +export default [ + { + path: '/dashboard', + component: LAYOUT, + redirect: '/dashboard/base', + name: 'dashboard', + meta: { + title: { + zh_CN: '仪表盘', + en_US: 'Dashboard', + }, + icon: shallowRef(DashboardIcon), + orderNo: 0, + }, + children: [ + { + path: 'base', + name: 'DashboardBase', + component: () => import('@/pages/dashboard/base/index.vue'), + meta: { + title: { + zh_CN: '概览仪表盘', + en_US: 'Overview', + }, + }, + }, + { + path: 'dashboard', + name: 'Dashboard', + meta: { + title: { + zh_CN: '报表', + en_US: 'Dashboard', + }, + }, + children: [ + { + path: 'detail', + name: 'Detail', + component: () => import('@/pages/dashboard/detail/index.vue'), + meta: { + title: { + zh_CN: '统计报表', + en_US: 'Dashboard Detail', + }, + }, + }, + ], + }, + ], + }, +] satisfies RouteRecordRaw[]; diff --git a/src/router/modules/result.ts b/src/router/modules/result.ts new file mode 100644 index 0000000..deaf444 --- /dev/null +++ b/src/router/modules/result.ts @@ -0,0 +1,84 @@ +import type { RouteRecordRaw } from 'vue-router'; + +import { LAYOUT } from '@/utils/route/constant'; + +export default [ + { + path: '/result', + name: 'result', + component: LAYOUT, + redirect: '/result/success', + meta: { + title: { + zh_CN: '结果页', + en_US: 'Result', + }, + icon: 'check-circle', + }, + children: [ + { + path: 'success', + name: 'ResultSuccess', + component: () => import('@/pages/result/success/index.vue'), + meta: { + title: { + zh_CN: '成功页', + en_US: 'Success', + }, + }, + }, + { + path: 'fail', + name: 'ResultFail', + component: () => import('@/pages/result/fail/index.vue'), + meta: { + title: { + zh_CN: '失败页', + en_US: 'Fail', + }, + }, + }, + { + path: 'network-error', + name: 'ResultNetworkError', + component: () => import('@/pages/result/network-error/index.vue'), + meta: { + title: { + zh_CN: '网络异常', + en_US: 'Network Error', + }, + }, + }, + { + path: '403', + name: 'Result403', + component: () => import('@/pages/result/403/index.vue'), + meta: { title: { zh_CN: '无权限', en_US: 'Forbidden' } }, + }, + { + path: '404', + name: 'Result404', + component: () => import('@/pages/result/404/index.vue'), + meta: { title: { zh_CN: '访问页面不存在页', en_US: 'Not Found' } }, + }, + { + path: '500', + name: 'Result500', + component: () => import('@/pages/result/500/index.vue'), + meta: { title: { zh_CN: '服务器出错页', en_US: 'Server Error' } }, + }, + { + path: 'browser-incompatible', + name: 'ResultBrowserIncompatible', + component: () => import('@/pages/result/browser-incompatible/index.vue'), + meta: { title: { zh_CN: '浏览器不兼容页', en_US: 'BrowserIncompatible' } }, + }, + { + path: 'maintenance', + name: 'ResultMaintenance', + component: () => import('@/pages/result/maintenance/index.vue'), + meta: { title: { zh_CN: '系统维护页', en_US: 'Maintenance' } }, + }, + ], + }, +] satisfies RouteRecordRaw[]; diff --git a/src/router/modules/user.ts b/src/router/modules/user.ts new file mode 100644 index 0000000..d5a4623 --- /dev/null +++ b/src/router/modules/user.ts @@ -0,0 +1,37 @@ +import { LogoutIcon } from 'tdesign-icons-vue-next'; +import { shallowRef } from 'vue'; +import type { RouteRecordRaw } from 'vue-router'; + +import { LAYOUT } from '@/utils/route/constant'; + +export default [ + { + path: '/user', + name: 'user', + component: LAYOUT, + redirect: '/user/index', + meta: { title: { zh_CN: '个人中心', en_US: 'User Center' }, icon: 'user-circle' }, + children: [ + { + path: 'index', + name: 'UserIndex', + component: () => import('@/pages/user/index.vue'), + meta: { title: { zh_CN: '个人中心', en_US: 'User Center' } }, + }, + ], + }, + { + path: '/loginRedirect', + name: 'loginRedirect', + redirect: '/login', + meta: { title: { zh_CN: '登录页', en_US: 'Login' }, icon: shallowRef(LogoutIcon) }, + component: () => import('@/layouts/blank.vue'), + children: [ + { + path: 'index', + redirect: '/login', + meta: { title: { zh_CN: '登录页', en_US: 'Login' } }, + }, + ], + }, +] satisfies RouteRecordRaw[]; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..862341a --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,15 @@ +import { createPinia } from 'pinia'; +import { createPersistedState } from 'pinia-plugin-persistedstate'; + +const store = createPinia(); +store.use(createPersistedState()); + +export { store }; + +export * from './modules/notification'; +export * from './modules/permission'; +export * from './modules/setting'; +export * from './modules/tabs-router'; +export * from './modules/user'; + +export default store; diff --git a/src/store/modules/notification.ts b/src/store/modules/notification.ts new file mode 100644 index 0000000..460597b --- /dev/null +++ b/src/store/modules/notification.ts @@ -0,0 +1,64 @@ +import { defineStore } from 'pinia'; + +import { t } from '@/locales'; +import type { NotificationItem } from '@/types/interface'; + +const getMsgData = () => [ + { + id: '123', + content: t('pages.notification.msg1'), + type: t('pages.notification.contract'), + status: true, + collected: false, + date: '2021-01-01 08:00', + quality: 'high', + }, + { + id: '124', + content: t('pages.notification.msg2'), + type: t('pages.notification.invoice'), + status: true, + collected: false, + date: '2021-01-01 08:00', + quality: 'low', + }, + { + id: '125', + content: t('pages.notification.msg3'), + type: t('pages.notification.meeting'), + status: true, + collected: false, + date: '2021-01-01 08:00', + quality: 'middle', + }, + { + id: '126', + content: t('pages.notification.msg4'), + type: t('pages.notification.invoice'), + status: true, + collected: false, + date: '2021-01-01 08:00', + quality: 'low', + }, +]; + +type MsgDataType = ReturnType; + +export const useNotificationStore = defineStore('notification', { + state: () => ({ + msgData: getMsgData(), + }), + getters: { + unreadMsg: (state) => state.msgData.filter((item: NotificationItem) => item.status), + readMsg: (state) => state.msgData.filter((item: NotificationItem) => !item.status), + }, + actions: { + setMsgData(data: MsgDataType) { + this.msgData = data; + }, + refreshMsgData() { + this.msgData = getMsgData(); + }, + }, + persist: false, +}); diff --git a/src/store/modules/permission-fe.ts b/src/store/modules/permission-fe.ts new file mode 100644 index 0000000..d413baf --- /dev/null +++ b/src/store/modules/permission-fe.ts @@ -0,0 +1,71 @@ +// 前端 roles 控制菜单权限 通过登录后的角色对菜单就行过滤处理 +// 如果需要前端 roles 控制菜单权限 请使用此文件代码替换 permission.ts 的内容 + +import cloneDeep from 'lodash/cloneDeep'; +import { defineStore } from 'pinia'; +import type { RouteRecordRaw } from 'vue-router'; + +import router, { allRoutes } from '@/router'; +import { store } from '@/store'; + +function filterPermissionsRouters(routes: Array, roles: Array) { + const res: Array = []; + const removeRoutes: Array = []; + routes.forEach((route) => { + const children: Array = []; + route.children?.forEach((childRouter) => { + const roleCode = childRouter.meta?.roleCode || childRouter.name; + if (roles.includes(roleCode)) { + children.push(childRouter); + } else { + removeRoutes.push(childRouter); + } + }); + if (children.length > 0) { + route.children = children; + res.push(route); + } + }); + return { accessedRouters: res, removeRoutes }; +} + +export const usePermissionStore = defineStore('permission', { + state: () => ({ + whiteListRouters: ['/login'], + routers: [] as Array, + removeRoutes: [] as Array, + }), + actions: { + async initRoutes(roles: Array) { + let accessedRouters = []; + + let removeRoutes: Array = []; + // special token + if (roles.includes('all')) { + accessedRouters = cloneDeep(allRoutes); + } else { + const res = filterPermissionsRouters(allRoutes, roles); + accessedRouters = res.accessedRouters; + removeRoutes = res.removeRoutes; + } + + this.routers = accessedRouters; + this.removeRoutes = removeRoutes; + + removeRoutes.forEach((item: RouteRecordRaw) => { + if (router.hasRoute(item.name!)) { + router.removeRoute(item.name!); + } + }); + }, + async restore() { + this.removeRoutes.forEach((item: RouteRecordRaw) => { + router.addRoute(item); + }); + }, + }, +}); + +export function getPermissionStore() { + return usePermissionStore(store); +} diff --git a/src/store/modules/permission.ts b/src/store/modules/permission.ts new file mode 100644 index 0000000..e38d145 --- /dev/null +++ b/src/store/modules/permission.ts @@ -0,0 +1,54 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { defineStore } from 'pinia'; +import type { RouteRecordRaw } from 'vue-router'; + +import type { RouteItem } from '@/api/model/permissionModel'; +import { getMenuList } from '@/api/permission'; +import router, { fixedRouterList, homepageRouterList } from '@/router'; +import { store } from '@/store'; +import { transformObjectToRoute } from '@/utils/route'; + +export const usePermissionStore = defineStore('permission', { + state: () => ({ + whiteListRouters: ['/login'], + routers: [] as Array, + removeRoutes: [] as Array, + asyncRoutes: [] as Array, + }), + actions: { + async initRoutes() { + const accessedRouters = this.asyncRoutes; + + // 在菜单展示全部路由 + this.routers = cloneDeep([...homepageRouterList, ...accessedRouters, ...fixedRouterList]); + // 在菜单只展示动态路由和首页 + // this.routers = [...homepageRouterList, ...accessedRouters]; + // 在菜单只展示动态路由 + // this.routers = [...accessedRouters]; + }, + async buildAsyncRoutes() { + try { + // 发起菜单权限请求 获取菜单列表 + const asyncRoutes: Array = (await getMenuList()).list; + this.asyncRoutes = transformObjectToRoute(asyncRoutes); + await this.initRoutes(); + return this.asyncRoutes; + } catch (error) { + throw new Error("Can't build routes", error as ErrorOptions); + } + }, + async restoreRoutes() { + // 不需要在此额外调用initRoutes更新侧边导肮内容,在登录后asyncRoutes为空会调用 + this.asyncRoutes.forEach((item: RouteRecordRaw) => { + if (item.name) { + router.removeRoute(item.name); + } + }); + this.asyncRoutes = []; + }, + }, +}); + +export function getPermissionStore() { + return usePermissionStore(store); +} diff --git a/src/store/modules/setting.ts b/src/store/modules/setting.ts new file mode 100644 index 0000000..535d0d1 --- /dev/null +++ b/src/store/modules/setting.ts @@ -0,0 +1,111 @@ +import keys from 'lodash/keys'; +import { defineStore } from 'pinia'; +import { Color } from 'tvision-color'; + +import type { TColorSeries } from '@/config/color'; +import { DARK_CHART_COLORS, LIGHT_CHART_COLORS } from '@/config/color'; +import STYLE_CONFIG from '@/config/style'; +import { store } from '@/store'; +import type { ModeType } from '@/types/interface'; +import { generateColorMap, insertThemeStylesheet } from '@/utils/color'; + +const state: Record = { + ...STYLE_CONFIG, + showSettingPanel: false, + colorList: {} as TColorSeries, + chartColors: LIGHT_CHART_COLORS, +}; + +export type TState = typeof state; +export type TStateKey = keyof typeof state; + +export const useSettingStore = defineStore('setting', { + state: () => state, + getters: { + showSidebar: (state) => state.layout !== 'top', + showSidebarLogo: (state) => state.layout === 'side', + showHeaderLogo: (state) => state.layout !== 'side', + displayMode: (state): ModeType => { + if (state.mode === 'auto') { + const media = window.matchMedia('(prefers-color-scheme:dark)'); + if (media.matches) { + return 'dark'; + } + return 'light'; + } + return state.mode as ModeType; + }, + displaySideMode: (state): ModeType => { + return state.sideMode as ModeType; + }, + }, + actions: { + async changeMode(mode: ModeType | 'auto') { + let theme = mode; + + if (mode === 'auto') { + theme = this.getMediaColor(); + } + const isDarkMode = theme === 'dark'; + + document.documentElement.setAttribute('theme-mode', isDarkMode ? 'dark' : ''); + + this.chartColors = isDarkMode ? DARK_CHART_COLORS : LIGHT_CHART_COLORS; + }, + async changeSideMode(mode: ModeType) { + const isDarkMode = mode === 'dark'; + + document.documentElement.setAttribute('side-mode', isDarkMode ? 'dark' : ''); + }, + getMediaColor() { + const media = window.matchMedia('(prefers-color-scheme:dark)'); + + if (media.matches) { + return 'dark'; + } + return 'light'; + }, + changeBrandTheme(brandTheme: string) { + const mode = this.displayMode; + // 以主题色加显示模式作为键 + const colorKey = `${brandTheme}[${mode}]`; + let colorMap = this.colorList[colorKey]; + // 如果不存在色阶,就需要计算 + if (colorMap === undefined) { + const [{ colors: newPalette, primary: brandColorIndex }] = Color.getColorGradations({ + colors: [brandTheme], + step: 10, + remainInput: false, // 是否保留输入 不保留会矫正不合适的主题色 + }); + colorMap = generateColorMap(brandTheme, newPalette, mode, brandColorIndex); + this.colorList[colorKey] = colorMap; + } + // TODO 需要解决不停切换时有反复插入 style 的问题 + insertThemeStylesheet(brandTheme, colorMap, mode); + document.documentElement.setAttribute('theme-color', brandTheme); + }, + updateConfig(payload: Partial) { + for (const key in payload) { + if (payload[key as TStateKey] !== undefined) { + this[key as TStateKey] = payload[key as TStateKey]; + } + if (key === 'mode') { + this.changeMode(payload[key] as ModeType); + } + if (key === 'sideMode') { + this.changeSideMode(payload[key] as ModeType); + } + if (key === 'brandTheme') { + this.changeBrandTheme(payload[key]); + } + } + }, + }, + persist: { + pick: [...keys(STYLE_CONFIG), 'colorList', 'chartColors'], + }, +}); + +export function getSettingStore() { + return useSettingStore(store); +} diff --git a/src/store/modules/tabs-router.ts b/src/store/modules/tabs-router.ts new file mode 100644 index 0000000..bf04a6e --- /dev/null +++ b/src/store/modules/tabs-router.ts @@ -0,0 +1,98 @@ +import { defineStore } from 'pinia'; + +import { store } from '@/store'; +import type { TRouterInfo, TTabRouterType } from '@/types/interface'; +import { normalizePath } from '@/utils/route'; + +const homeRoute: Array = [ + { + path: '/dashboard/base', + routeIdx: 0, + title: { zh_CN: '仪表盘', en_US: 'Dashboard' }, + name: 'DashboardBase', + isHome: true, + }, +]; + +const state = { + tabRouterList: homeRoute, + isRefreshing: false, +}; + +// 不需要做多标签tabs页缓存的列表 值为每个页面对应的name 如 DashboardDetail +// const ignoreCacheRoutes = ['DashboardDetail']; +const ignoreCacheRoutes = ['login']; + +export const useTabsRouterStore = defineStore('tabsRouter', { + state: () => state, + getters: { + tabRouters: (state: TTabRouterType) => state.tabRouterList, + refreshing: (state: TTabRouterType) => state.isRefreshing, + }, + actions: { + // 处理刷新 + toggleTabRouterAlive(routeIdx: number) { + this.isRefreshing = !this.isRefreshing; + this.tabRouters[routeIdx].isAlive = !this.tabRouters[routeIdx].isAlive; + }, + // 处理新增 + appendTabRouterList(newRoute: TRouterInfo) { + // 不要将判断条件newRoute.meta.keepAlive !== false修改为newRoute.meta.keepAlive,starter默认开启保活,所以meta.keepAlive未定义时也需要进行保活,只有显式说明false才禁用保活。 + const needAlive = !ignoreCacheRoutes.includes(newRoute.name as string) && newRoute.meta?.keepAlive !== false; + const normalizedPath = normalizePath(newRoute.path); + const existed = this.tabRouters.some((route: TRouterInfo) => { + return route.path === normalizedPath || normalizePath(route.path) === normalizedPath; + }); + + if (!existed) { + this.tabRouterList = this.tabRouterList.concat({ ...newRoute, path: normalizedPath, isAlive: needAlive }); + } + }, + // 处理关闭当前 + subtractCurrentTabRouter(newRoute: TRouterInfo) { + const { routeIdx } = newRoute; + if (routeIdx === undefined) return; + this.tabRouterList = this.tabRouterList.slice(0, routeIdx).concat(this.tabRouterList.slice(routeIdx + 1)); + }, + // 处理关闭右侧 + subtractTabRouterBehind(newRoute: TRouterInfo) { + const { routeIdx } = newRoute; + if (routeIdx === undefined) return; + const homeIdx: number = this.tabRouters.findIndex((route: TRouterInfo) => route.isHome); + let tabRouterList: Array = this.tabRouterList.slice(0, routeIdx + 1); + if (routeIdx < homeIdx) { + tabRouterList = tabRouterList.concat(homeRoute); + } + this.tabRouterList = tabRouterList; + }, + // 处理关闭左侧 + subtractTabRouterAhead(newRoute: TRouterInfo) { + const { routeIdx } = newRoute; + if (routeIdx === undefined) return; + const homeIdx: number = this.tabRouters.findIndex((route: TRouterInfo) => route.isHome); + let tabRouterList: Array = this.tabRouterList.slice(routeIdx); + if (routeIdx > homeIdx) { + tabRouterList = homeRoute.concat(tabRouterList); + } + this.tabRouterList = tabRouterList; + }, + // 处理关闭其他 + subtractTabRouterOther(newRoute: TRouterInfo) { + const { routeIdx } = newRoute; + if (routeIdx === undefined) return; + const homeIdx: number = this.tabRouters.findIndex((route: TRouterInfo) => route.isHome); + this.tabRouterList = routeIdx === homeIdx ? homeRoute : homeRoute.concat([this.tabRouterList?.[routeIdx]]); + }, + removeTabRouterList() { + this.tabRouterList = []; + }, + initTabRouterList(newRoutes: TRouterInfo[]) { + newRoutes?.forEach((route: TRouterInfo) => this.appendTabRouterList(route)); + }, + }, + persist: true, +}); + +export function getTabsRouterStore() { + return useTabsRouterStore(store); +} diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts new file mode 100644 index 0000000..884fac3 --- /dev/null +++ b/src/store/modules/user.ts @@ -0,0 +1,88 @@ +import { defineStore } from 'pinia'; + +import { t } from '@/locales'; +import { usePermissionStore } from '@/store'; +import type { UserInfo } from '@/types/interface'; + +const InitUserInfo: UserInfo = { + name: '', // 用户名,用于展示在页面右上角头像处 + roles: [], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用 +}; + +export const useUserStore = defineStore('user', { + state: () => ({ + token: 'main_token', // 默认token不走权限 + userInfo: { ...InitUserInfo }, + }), + getters: { + roles: (state) => { + return state.userInfo?.roles; + }, + }, + actions: { + async login(userInfo: Record) { + const mockLogin = async (userInfo: Record) => { + // 登录请求流程 + console.log(`用户信息:`, userInfo); + // const { account, password } = userInfo; + // if (account !== 'td') { + // return { + // code: 401, + // message: t('pages.login.validation.accountNotFound'), + // }; + // } + // if (['main_', 'dev_'].indexOf(password) === -1) { + // return { + // code: 401, + // message: t('pages.login.validation.passwordError'), + // }; + // } + // const token = { + // main_: 'main_token', + // dev_: 'dev_token', + // }[password]; + return { + code: 200, + message: t('pages.login.loginSuccess'), + data: 'main_token', + }; + }; + + const res = await mockLogin(userInfo); + if (res.code === 200) { + this.token = res.data; + } else { + throw res; + } + }, + async getUserInfo() { + const mockRemoteUserInfo = async (token: string) => { + if (token === 'main_token') { + return { + name: 'Tencent', + roles: ['all'], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用 + }; + } + return { + name: 'td_dev', + roles: ['UserIndex', 'DashboardBase', 'login'], // 前端权限模型使用 如果使用请配置modules/permission-fe.ts使用 + }; + }; + const res = await mockRemoteUserInfo(this.token); + + this.userInfo = res; + }, + async logout() { + this.token = ''; + this.userInfo = { ...InitUserInfo }; + }, + }, + persist: { + afterHydrate: () => { + const permissionStore = usePermissionStore(); + permissionStore.initRoutes(); + }, + key: 'user', + pick: ['token'], + }, +}); diff --git a/src/style/font-family.less b/src/style/font-family.less new file mode 100644 index 0000000..67a31b4 --- /dev/null +++ b/src/style/font-family.less @@ -0,0 +1,7 @@ +@font-face { + font-family: 'TencentSansW7'; + src: url('data:application/font-woff;charset=utf-8;base64,d09GRgABAAAAAAusAA4AAAAAEJQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAALkAAAABwAAAAchqPqzUdERUYAAAtwAAAAHgAAAB4AKQAbT1MvMgAAAbgAAABZAAAAYGmceoNjbWFwAAACYAAAAJcAAAHsPmfPZmdhc3AAAAtkAAAADAAAAAwACAAbZ2x5ZgAAAywAAAW8AAAG/Ivn/ztoZWFkAAABRAAAADYAAAA2E+AL5GhoZWEAAAF8AAAAIAAAACQIawJ9aG10eAAAAhQAAABMAAAATCG/Auxsb2NhAAADAAAAACwAAAAsDjIQIm1heHAAAAGcAAAAGgAAACAAfgBDbmFtZQAACOgAAAIUAAAEm0zGvtJwb3N0AAAK/AAAAGYAAAB/4wuGdnByZXAAAAL4AAAACAAAAAhwAgESAAEAAAABBR/xlpGAXw889QALA+gAAAAA2Ac3gwAAAADY+IxB//L/HAPPAwAAAAAIAAIAAAAAAAB42mNgZGBgWf7vFAMD84v/n/7vZD7PABRBAYIAwxQH7XjaY2BkYGAQZXBiYGEAAUYGGEiBUAAMEQDCAAB42mNgYepm2sPAysDA1MUUwcDA4A2hGeMYjBjNgKI8HMxMTCz8TCwLGJj2CzCAgRiI8PX382d0YGBMEmQ2+u/FcIJlOVA9CwMjSI6JlekwkFJgYAQAR1kL+QAAAAJYAHYAAAAAAU0AAAEEAAACUAAhAlYAFQJUACACKgAdAZUANgEUABUBYAAkA5wAFQINABsBqAA0AnAAKgJYACoD6ACF//YANP/yACN42mNgYGBmgGAZBkYGEHgG5DGC+SwMp4C0HIMAUISPQYEhiSGNIZMhl6GUoZJhgeIkfS6/N4GpQQuSBP//B+tMZEgByucwFGOT/7/4/6L/C/7P+z/z/7T/yffqLrJvVFu3Zm3xPJBtcgz4ADPFkIGRDWgMIcBAIWBhZWBj52Bg4GRg4OIGi/Dw8gFJfgYqA/JcCgA99Se8ALgB/4W4AAGNAAAAFAAUABQAFABSAIIAsgD6ASIBOAFYAYIBxgHwAhQCRAJaAogCygMYA3542k1Ua2xTZRj+LmtP23Vdz2lPz3pZb2dr1+u2nq2H0d3Z2OhI5mC4AZMBo0gM98E0oRn1AqgoIYDG4BAkakDkJ4iyiCZGAiISUH8YjIQfEhNUMCoJrme+bTfkx/nxveec53ne533eDxE0Nn0V/0V2I4oYhATWw1ZKrDiGx5Vfzp6NkXPZ7mH8ECGCPNNXiZWokANVIYRjFt7MUI83iuvrWnAzTWA5Xl/nC2G/SZJFr7oUq3mzBaf7F5S0Kt+F59i1aq0j0tbwJmXcwUtsz3HHhEtQaYr0RFUbL0sqB8yRClvcG2uwa7hKg4VLKdFjZmeEN5SwwM0Dd4DcRaXAXuAuxQYqekwSIxRoqRRrxjLevrK/RNCUhXt7lYevpQMf6StjW1sz/gCrIqqm+Z7krsjCmJU67/zexrvMykE+CniA7wb8oUf4EhW9vtm2qGQSvQY808+t9Ov/5ih0zlhfyytfbGodd/ht2rJiH7k7dTtP0NQTYpUo3qDzSnsSVpc5j18O+DzRIzeqyel34nwDOISZHGoCM6I3SvKMRJppKM8pxeIy3tbU4S4y6Oc231Sp3OFLgXMV7dV2xq8L9K9IUZvY5XAZi4wfms2U6K21PoHvnVfvchitXEorhH2O4K253RUTnIYt0Xv4ITanB6M4zPwm9GuBQ4GIEaloEmVJzvXM4JeqXaxWfYg7tCi9qIddqNZZg53yKEv2lLgkEWaDr6fERHfEnEL5/mA+uJzcLmTIJEsMNUl0t5AR7o+kR8jqTCa7nsjZS3nuCvg2DtwcpC3ftQzfgg1ODMRfBjt8eiP8ZLAE7HNDsRJPUKhYTmqy13osvJGm4H/gomsgh8Ksk2oGpM+kAHqAh0wmuitpSWO6SWer2NOX7vu8D2SouFCACSSVk3hgjmBllRp8Takhck6THTT9DZjVoMkPprfgmSE8igHzKNOJ//MX4X4rN6m0VF022lan02g4R11DNngDahqoPQe1YoZ31SRiKWN5fammqNgddMiRcNheAoXIbKGlKsoXo7w3daDjFDmNbOAiM+uJOONSbjoSIzEPI1owpyO0oL6twlAcSbI9zrTzKIePTdeaeZamOHe4zJtA06kTJ3J+BSF7vdCbKZ/swsbGZzyHZvCN9BrlzmN+vUVU2UsFq/CAcjJnVV5bcNpB2gCnPI9jILDquZ1rwflleQzwqx1ri8PJzh+4K3GnUcPsg32pSdfAJmYA+dPm+a61rBj0N3Z5ktio3Gu1uAqZ3IVW0R2Ar4GDwIiyp97jJ5MXLuzbn71IGvYT1fXrR545kvdp+p/pNB0nvyI7QiqR+A2UEZupLPmjOK/KIlg4On741U6xf3hj49Ha0e2bov3tVFd6oOx2uVvb9dlLeOTjxt1797XUvDBx+snTOv1Sk/2y0g7YRTCDNP2WIqSHhLqQCHQGnEuYmpHhhvFYBNHnV8UsUPJLpjjwYZNA8ZC+yv7Uxu5QiG/vMWOfNzzq9uH7XqFoyz3byRWUv1ClnNowsoqi+CYp248fhN0TC94YXrSkYyRod03dWkYOmpdOfUAetGWTiE7/CRomQYML+VG0kHOG5AjjpjjE0kAYMc7JubMsqZnK3GvqMQmNOHcR4u9t5Woai31t3xXxYVrcaZQXt+5cOWZZV7ZXp94Io6u1C5Q+rZzY+pP62YEdZEmpx6QcUb4ZnMJ2nXbs2uWtb+P57w6sSoYytQYnl62juhVEnT1e2HURtF0EbQnUBsrAD9AADglwhfgLHkWxX2TiMqPOeyQzftinXHjhbVwuJVIdiOY9fvBRstDzyh9Ys6B6W2h0HnvO85DbvMsa2xJhNPgiPzBkaN4W/NF+0PaOd/ug7Yz+DNfVp3/v5+Jx8yRdT3F5dLMorLXik6PrnhhYfKAxM/hyb0Oanycm3+86bHMPC6JyZfB8YJnN8sngi4xqqdq3nN0//vzOTNXq5YsR+g8984WfeNq1Us1qFEEQ/npnk0X8IQGJIjnUSRLYLLt7MMlFCHvNKRvMuTPTmZ1kdib0zAY3ePMFfAAvigi5+Ry+gA8iiOLFr3tbTFZWcnGgu76qrvqqpqoAPMQ3KMy+A3wMWOGBehRwAy31LOAIayoPuEmfdwEv4Z76HPAy7quvAbfwqvEz4BXcjd4EvIpG9J5sqnmH2gfP7LDCOn4E3CD/04AjtNXzgJtYV68DXsJj9SngZTxRXwJu4XtDBbyCtehlwKtoRm8xQIlzTGGRIcUINQRXPH100cMOtgLape0QBgVif9dBjxlTkCPnSckj2MCQNudzGeQmrS5PB22ifcYmxII9RuWUf3JXXjOUhvKCt/PEoDyf2iwd1XIl/W5vZ4vXrhyaIjZFTRmPijIv06lsDEemuOTZlEHZact+nXRkL8/FR1diTWXshUnIefNnhtCULv0Rtvk4ox7qopKjbbcNhhVOWK1mXTgw6STX9t8kMh91k1RuRfJXJS98Zyp2rKSbcDIdzqfPB2OrrCyk1+n2F3HOMzrC+aFmPrcOg0i9XvukbhhCbPmaUBv73zqjrcTJf1gPV7PL6PK4yGN6L6oq882IvaWm/0w/ZfOt9014x3yZta1yS/V7fbJKNBcjzaraWJNIbXVixtqeSXly6x3TRSJjPZVjc50qKyQ2ttaUpxObVUkW15xRtXD9rg8Hs3FxRr8ATJnl93jaY2BiAIP/zQxGDNiAKBAzMjAxMjG4MLgyuDN4MHgy+DD4MwQwhDGEM0QwxDAyM7IwsjKyMbKzl+ZlGhgYGHIlFhXllxdlpmeUgISM3AwcQbSJq6sziDY1cjQA0WZGhoYAgBwU3AAAAAEAAgAIAAr//wAPAAEAAAAMAAAAFgAAAAIAAQADABQAAQAEAAAAAgAAAAAAAAABAAAAANWkJwgAAAAA2Ac3gwAAAADY+IxB') + format('woff'); + font-weight: normal; + font-style: normal; +} diff --git a/src/style/index.less b/src/style/index.less new file mode 100644 index 0000000..dfda36c --- /dev/null +++ b/src/style/index.less @@ -0,0 +1,2 @@ +@import './font-family.less'; +@import './reset.less'; diff --git a/src/style/layout.less b/src/style/layout.less new file mode 100644 index 0000000..dd67bac --- /dev/null +++ b/src/style/layout.less @@ -0,0 +1,217 @@ +@import './font-family.less'; + +// layout rewrite + +.t-layout__sider { + width: fit-content; +} + +.t-button + .t-button { + margin-left: var(--td-comp-margin-s); +} + +.t-transfer, +.t-jumper, +.t-pagination-mini { + .t-button + .t-button { + margin-left: 0; + } +} + +.@{starter-prefix}-link { + color: var(--td-brand-color); + text-decoration: none; + margin-right: 24px; + cursor: pointer; + transition: color 0.2s cubic-bezier(0.38, 0, 0.24, 1); +} + +.left-operation-container, +.operation-container { + .t-button + .t-button { + margin-left: var(--td-comp-margin-s); + } +} + +.t-layout.t-layout--with-sider { + > .t-layout { + flex: 1; + min-width: 760px; + } +} + +.t-menu--dark .t-menu__operations .t-icon { + color: rgb(255 255 255 / 55%); + + &:hover { + cursor: pointer; + } +} + +.t-default-menu.t-menu--dark { + background: var(--td-gray-color-13); +} + +.@{starter-prefix} { + // 布局元素调整 + &-wrapper { + height: 100vh; + display: flex; + flex-direction: column; + } + + &-main-wrapper { + height: 500px; + overflow: scroll; + } + + &-side-nav-layout { + &-relative { + height: 100%; + } + } + + &-content-layout { + padding: var(--td-comp-paddingTB-xl) var(--td-comp-paddingLR-xl); + } + + &-layout { + height: calc(100vh - var(--td-comp-size-xxxl)); + overflow-y: scroll; + + &-tabs-nav { + max-width: 100%; + position: fixed; + overflow: visible; + z-index: 100; + } + &-tabs-nav + .@{starter-prefix}-content-layout { + padding-top: var(--td-comp-paddingTB-xxl); + } + + &::-webkit-scrollbar { + width: 8px; + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 6px; + border: 2px solid transparent; + background-clip: content-box; + background-color: var(--td-scrollbar-color); + } + } + + &-footer-layout { + padding: 0; + margin-bottom: var(--td-comp-margin-xxl); + } + + // slideBar + &-sidebar-layout { + height: 100%; + } + + &-sidebar-compact { + width: 64px; + } + + &-sidebar-layout-side { + z-index: 100; + } + + &-side-nav { + position: fixed; + top: 0; + bottom: 0; + z-index: 200; + transition: all 0.3s; + min-height: 100%; + + &-mix { + top: var(--td-comp-size-xxxl); + + &-fixed { + top: var(--td-comp-size-xxxl); + z-index: 0; + } + } + + &-no-fixed { + position: relative; + z-index: 1; + } + + &-no-logo { + z-index: 1; + } + + &-logo-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + + &:hover { + cursor: pointer; + } + } + + &-logo-t-logo { + height: var(--td-comp-size-s); + width: 100%; + } + + &-logo-tdesign-logo { + margin-right: var(--td-comp-margin-xxxl); + height: var(--td-comp-size-s); + width: 100%; + color: var(--td-text-color-primary); + } + + &-logo-normal { + color: var(--td-brand-color); + font: var(--td-font-body-large); + transition: all 0.3s; + } + } + + &-side-nav-placeholder { + flex: 1 1 232px; + min-width: 232px; + transition: all 0.3s; + + &-hidden { + flex: 1 1 72px; + min-width: 72px; + transition: all 0.3s; + } + } +} + +.route-tabs-dropdown { + .t-icon { + margin-right: 8px; + } +} + +.logo-container { + cursor: pointer; + display: inline-flex; + margin-left: 24px; +} + +.version-container { + color: var(--td-text-color-primary); + opacity: 0.4; + transition: all 0.3s; + vertical-align: middle; +} + +.t-menu__popup { + z-index: 1000; +} + +.container-base-margin-top { + margin-top: 16px; +} diff --git a/src/style/reset.less b/src/style/reset.less new file mode 100644 index 0000000..cae2cfd --- /dev/null +++ b/src/style/reset.less @@ -0,0 +1,39 @@ +// 对部分样式进行重置 +body { + background: var(--td-bg-color-page); + color: var(--td-text-color-secondary); + font: var(--td-font-body-medium); + font-family: -apple-system, BlinkMacSystemFont, var(--td-font-family); + -webkit-font-smoothing: antialiased; + padding: 0; + margin: 0; +} + +pre { + font-family: var(--td-font-family); +} + +ul, +dl, +li, +dd, +dt { + margin: 0; + padding: 0; + list-style: none; +} + +figure, +h1, +h2, +h3, +h4, +h5, +h6, +p { + margin: 0; +} + +* { + box-sizing: border-box; +} diff --git a/src/style/variables.less b/src/style/variables.less new file mode 100644 index 0000000..d0889f3 --- /dev/null +++ b/src/style/variables.less @@ -0,0 +1,26 @@ +/** 公共前缀 */ +@starter-prefix: tdesign-starter; + +// 颜色、尺寸、阴影、圆角、字体 variables 请参考 https://tdesign.tencent.com/starter/docs/vue/design-token +// 响应式断点 +@screen-sm: 768px; +@screen-md: 992px; +@screen-lg: 1200px; +@screen-xl: 1400px; + +@screen-sm-min: @screen-sm; +@screen-md-min: @screen-md; +@screen-lg-min: @screen-lg; +@screen-xl-min: @screen-xl; + +@screen-sm-max: calc(@screen-md-min - 1px); +@screen-md-max: calc(@screen-lg-min - 1px); +@screen-lg-max: calc(@screen-xl-min - 1px); + +// 动画 +@anim-time-fn-easing: cubic-bezier(0.38, 0, 0.24, 1); +@anim-time-fn-ease-out: cubic-bezier(0, 0, 0.15, 1); +@anim-time-fn-ease-in: cubic-bezier(0.82, 0, 1, 0.9); +@anim-duration-base: 0.2s; +@anim-duration-moderate: 0.24s; +@anim-duration-slow: 0.28s; diff --git a/src/types/axios.d.ts b/src/types/axios.d.ts new file mode 100644 index 0000000..1c90329 --- /dev/null +++ b/src/types/axios.d.ts @@ -0,0 +1,98 @@ +import type { AxiosRequestConfig } from 'axios'; + +/** + * Axios请求配置 + */ +export interface RequestOptions { + /** + * 接口地址 + * + * 例: http://www.baidu.com/api + */ + apiUrl?: string; + /** + * 是否自动添加接口前缀 + * + * 例: http://www.baidu.com/api + * urlPrefix: 'api' + */ + isJoinPrefix?: boolean; + /** + * 接口前缀 + */ + urlPrefix?: string; + /** + * POST请求的时候添加参数到Url中 + */ + joinParamsToUrl?: boolean; + /** + * 格式化提交参数时间 + */ + formatDate?: boolean; + /** + * 是否需要对响应数据进行处理 + */ + isTransformResponse?: boolean; + /** + * 是否返回原生响应头 + * + * 例: 需要获取响应头时使用该属性 + */ + isReturnNativeResponse?: boolean; + /** + * 是否忽略请求取消令牌 + * + * 如果启用,则重复请求时不进行处理 + * + * 如果禁用,则重复请求时会取消当前请求 + */ + ignoreCancelToken?: boolean; + /** + * 自动对请求添加时间戳参数 + */ + joinTime?: boolean; + /** + * 是否携带Token + */ + withToken?: boolean; + /** + * 重试配置 + */ + retry?: { + /** + * 重试次数 + */ + count: number; + /** + * 隔多久重试 + * + * 单位: 毫秒 + */ + delay: number; + }; + /** + * 接口级节流 + * + * 单位: 毫秒 + */ + throttle?: { + delay: number; + }; + /** + * 接口级防抖 + * + * 单位: 毫秒 + */ + debounce?: { + delay: number; + }; +} + +export interface Result { + code: number; + data: T; +} + +export interface AxiosRequestConfigRetry extends AxiosRequestConfig { + retryCount?: number; +} diff --git a/src/types/env.d.ts b/src/types/env.d.ts new file mode 100644 index 0000000..2336107 --- /dev/null +++ b/src/types/env.d.ts @@ -0,0 +1,9 @@ +interface ImportMetaEnv { + readonly VITE_IS_REQUEST_PROXY: string; + readonly VITE_API_URL: string; + readonly VITE_API_URL_PREFIX: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts new file mode 100644 index 0000000..72f19ba --- /dev/null +++ b/src/types/globals.d.ts @@ -0,0 +1,18 @@ +// 通用声明 + +// Vue +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent; + export default component; +} + +declare type ClassName = { [className: string]: any } | ClassName[] | string; + +declare module '*.svg' { + const CONTENT: string; + export default CONTENT; +} + +declare type Recordable = Record; diff --git a/src/types/interface.d.ts b/src/types/interface.d.ts new file mode 100644 index 0000000..bf39d0d --- /dev/null +++ b/src/types/interface.d.ts @@ -0,0 +1,57 @@ +import type { TabValue } from 'tdesign-vue-next'; +import type { Component, DefineComponent, FunctionalComponent } from 'vue'; +import type { LocationQueryRaw, RouteMeta, RouteRecordName } from 'vue-router'; + +import type { LocalizedTitle } from '@/locales'; + +export interface MenuRoute { + // TODO: menuitem 组件实际支持 string 类型但是类型错误,暂时使用 any 类型避免打包错误待组件类型修复 + path: any; + /** 多语言标题对象,如 { zh_CN: '仪表盘', en_US: 'Dashboard' } */ + title?: LocalizedTitle; + name?: string; + icon?: string | Component | FunctionalComponent | DefineComponent; + redirect?: string; + children: MenuRoute[]; + meta: RouteMeta; +} + +export type ModeType = 'dark' | 'light'; + +export interface UserInfo { + name: string; + roles: string[]; +} + +export interface NotificationItem { + id: string; + content: string; + type: string; + status: boolean; + collected: boolean; + date: string; + quality: string; +} + +export interface TRouterInfo { + path: string; + query?: LocationQueryRaw; + routeIdx?: number; + /** 多语言标题对象,如 { zh_CN: '仪表盘', en_US: 'Dashboard' } */ + title?: LocalizedTitle; + name?: RouteRecordName; + isAlive?: boolean; + isHome?: boolean; + meta?: any; +} + +export interface TTabRouterType { + isRefreshing: boolean; + tabRouterList: Array; +} + +export interface TTabRemoveOptions { + value: TabValue; + index: number; + e: MouseEvent; +} diff --git a/src/types/router.d.ts b/src/types/router.d.ts new file mode 100644 index 0000000..2901868 --- /dev/null +++ b/src/types/router.d.ts @@ -0,0 +1,22 @@ +import 'vue-router'; + +import type { Component, DefineComponent, FunctionalComponent } from 'vue'; + +import type { LocalizedTitle } from '@/locales'; + +export {}; +declare module 'vue-router' { + interface RouteMeta { + title?: LocalizedTitle; + icon?: string | Component | FunctionalComponent | DefineComponent; + expanded?: boolean; + orderNo?: number; + hidden?: boolean; + hiddenBreadcrumb?: boolean; + single?: boolean; + keepAlive?: boolean; + frameSrc?: string; + frameBlank?: boolean; + // roleCode?: string; // 前端 roles 控制菜单权限 + } +} diff --git a/src/utils/charts.ts b/src/utils/charts.ts new file mode 100644 index 0000000..2f28225 --- /dev/null +++ b/src/utils/charts.ts @@ -0,0 +1,38 @@ +import dayjs from 'dayjs'; + +/** + * 获取表头数据 + * + * @export + * @param {string[]} dateTime + * @param {number} divideNum + * @returns {string[]} timeArray + */ +export function getDateArray(dateTime: string[] = [], divideNum = 10): string[] { + const timeArray: string[] = []; + if (dateTime.length > 0) { + for (let i = 0; i < divideNum; i++) { + const dateAbsTime: number = (new Date(dateTime[1]).getTime() - new Date(dateTime[0]).getTime()) / divideNum; + const enhandTime: number = new Date(dateTime[0]).getTime() + dateAbsTime * i; + timeArray.push(dayjs(enhandTime).format('YYYY-MM-DD')); + } + } + + return timeArray; +} + +/** + * 获取随机数 + * + * @param {number} [num] + * @returns {number} resultNum + */ +export function getRandomArray(num = 100): number { + let resultNum = Number((Math.random() * num).toFixed(0)); + + if (resultNum <= 1) { + resultNum = 1; + } + + return resultNum; +} diff --git a/src/utils/color.ts b/src/utils/color.ts new file mode 100644 index 0000000..e07d5ba --- /dev/null +++ b/src/utils/color.ts @@ -0,0 +1,110 @@ +import type * as echarts from 'echarts/core'; +import trim from 'lodash/trim'; +import { Color } from 'tvision-color'; + +import type { TColorToken } from '@/config/color'; +import type { ModeType } from '@/types/interface'; + +/** + * 依据主题类型获取颜色 + * + * @export + * @returns {Array} themeColorList + */ +export function getColorFromTheme(): Array { + const theme = trim(getComputedStyle(document.documentElement).getPropertyValue('--td-brand-color')); + const themeColorList = Color.getRandomPalette({ + color: theme, + colorGamut: 'bright', + number: 8, + }); + + return themeColorList; +} + +/** 图表颜色 */ +export function getChartListColor(): Array { + return getColorFromTheme(); +} + +/** + * 更改图表主题颜色 + * + * @export + * @param {Array} chartsList + */ +export function changeChartsTheme(chartsList: echarts.EChartsType[]): void { + if (chartsList && chartsList.length) { + const chartChangeColor = getChartListColor(); + + for (let index = 0; index < chartsList.length; index++) { + const elementChart = chartsList[index]; + + if (elementChart) { + const optionVal = elementChart.getOption(); + + // 更改主题颜色 + optionVal.color = chartChangeColor; + + elementChart.setOption(optionVal, true); + } + } + } +} + +/** + * 根据当前主题色、模式等情景 计算最后生成的色阶 + */ +export function generateColorMap(theme: string, colorPalette: Array, mode: ModeType, brandColorIdx: number) { + const isDarkMode = mode === 'dark'; + + if (isDarkMode) { + colorPalette.reverse().map((color) => { + const [h, s, l] = Color.colorTransform(color, 'hex', 'hsl'); + return Color.colorTransform([h, Number(s) - 4, l], 'hsl', 'hex'); + }); + brandColorIdx = 5; + colorPalette[0] = `${colorPalette[brandColorIdx]}20`; + } + + const colorMap: TColorToken = { + '--td-brand-color': colorPalette[brandColorIdx], // 主题色 + '--td-brand-color-1': colorPalette[0], // light + '--td-brand-color-2': colorPalette[1], // focus + '--td-brand-color-3': colorPalette[2], // disabled + '--td-brand-color-4': colorPalette[3], + '--td-brand-color-5': colorPalette[4], + '--td-brand-color-6': colorPalette[5], + '--td-brand-color-7': brandColorIdx > 0 ? colorPalette[brandColorIdx - 1] : theme, // hover + '--td-brand-color-8': colorPalette[brandColorIdx], // 主题色 + '--td-brand-color-9': brandColorIdx > 8 ? theme : colorPalette[brandColorIdx + 1], // click + '--td-brand-color-10': colorPalette[9], + }; + return colorMap; +} + +/** + * 将生成的样式嵌入头部 + */ +export function insertThemeStylesheet(theme: string, colorMap: TColorToken, mode: ModeType) { + const isDarkMode = mode === 'dark'; + const root = !isDarkMode ? `:root[theme-color='${theme}']` : `:root[theme-color='${theme}'][theme-mode='dark']`; + + const styleSheet = document.createElement('style'); + styleSheet.type = 'text/css'; + styleSheet.textContent = `${root}{ + --td-brand-color: ${colorMap['--td-brand-color']}; + --td-brand-color-1: ${colorMap['--td-brand-color-1']}; + --td-brand-color-2: ${colorMap['--td-brand-color-2']}; + --td-brand-color-3: ${colorMap['--td-brand-color-3']}; + --td-brand-color-4: ${colorMap['--td-brand-color-4']}; + --td-brand-color-5: ${colorMap['--td-brand-color-5']}; + --td-brand-color-6: ${colorMap['--td-brand-color-6']}; + --td-brand-color-7: ${colorMap['--td-brand-color-7']}; + --td-brand-color-8: ${colorMap['--td-brand-color-8']}; + --td-brand-color-9: ${colorMap['--td-brand-color-9']}; + --td-brand-color-10: ${colorMap['--td-brand-color-10']}; + }`; + + document.head.appendChild(styleSheet); +} diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..bbfa47a --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,12 @@ +// 获取常用时间 +import dayjs from 'dayjs'; + +export const LAST_7_DAYS = [ + dayjs().subtract(7, 'day').format('YYYY-MM-DD'), + dayjs().subtract(1, 'day').format('YYYY-MM-DD'), +]; + +export const LAST_30_DAYS = [ + dayjs().subtract(30, 'day').format('YYYY-MM-DD'), + dayjs().subtract(1, 'day').format('YYYY-MM-DD'), +]; diff --git a/src/utils/request/Axios.ts b/src/utils/request/Axios.ts new file mode 100644 index 0000000..8faf7c0 --- /dev/null +++ b/src/utils/request/Axios.ts @@ -0,0 +1,324 @@ +import type { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + AxiosRequestHeaders, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; +import axios from 'axios'; +import cloneDeep from 'lodash/cloneDeep'; +import debounce from 'lodash/debounce'; +import isFunction from 'lodash/isFunction'; +import throttle from 'lodash/throttle'; +import { stringify } from 'qs'; + +import { ContentTypeEnum } from '@/constants'; +import type { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/axios'; + +import { AxiosCanceler } from './AxiosCancel'; +import type { CreateAxiosOptions } from './AxiosTransform'; + +/** + * Axios 模块 + */ +export class VAxios { + /** + * Axios实例句柄 + * @private + */ + private instance: AxiosInstance; + + /** + * Axios配置 + * @private + */ + private readonly options: CreateAxiosOptions; + + /** + * 节流/防抖包装后的请求函数 + * @private + */ + private throttledRequest: ((config: AxiosRequestConfigRetry, options?: RequestOptions) => void) | null = null; + + /** + * 待resolve的请求回调队列 + * @private + */ + private pendingResolvers: { + resolve: (value: any) => void; + }[] = []; + + constructor(options: CreateAxiosOptions) { + this.options = options; + this.instance = axios.create(options); + this.setupInterceptors(); + this.setupThrottleDebounce(); + } + + /** + * 创建Axios实例 + * @param config + * @private + */ + private createAxios(config: CreateAxiosOptions): void { + this.instance = axios.create(config); + } + + /** + * 获取数据处理类 + * @private + */ + private getTransform() { + const { transform } = this.options; + return transform; + } + + /** + * 获取Axios实例 + */ + getAxios(): AxiosInstance { + return this.instance; + } + + /** + * 配置Axios + * @param config + */ + configAxios(config: CreateAxiosOptions) { + if (!this.instance) return; + this.createAxios(config); + } + + /** + * 设置公共头部信息 + * @param headers + */ + setHeader(headers: Record): void { + if (!this.instance) return; + Object.assign(this.instance.defaults.headers, headers); + } + + /** + * 设置拦截器 + * @private + */ + private setupInterceptors() { + const transform = this.getTransform(); + if (!transform) return; + + const { requestInterceptors, requestInterceptorsCatch, responseInterceptors, responseInterceptorsCatch } = + transform; + const axiosCanceler = new AxiosCanceler(); + + // 请求拦截器 + this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { + // 如果忽略取消令牌,则不会取消重复的请求 + // @ts-expect-error 请求参数通过嵌套赋值 + const { ignoreCancelToken } = config.requestOptions; + const ignoreCancel = ignoreCancelToken ?? this.options.requestOptions?.ignoreCancelToken; + if (!ignoreCancel) axiosCanceler.addPending(config); + + if (requestInterceptors && isFunction(requestInterceptors)) { + config = requestInterceptors(config, this.options) as InternalAxiosRequestConfig; + } + + return config; + }, undefined); + + // 请求错误处理 + if (requestInterceptorsCatch && isFunction(requestInterceptorsCatch)) { + this.instance.interceptors.request.use(undefined, requestInterceptorsCatch); + } + + // 响应结果处理 + this.instance.interceptors.response.use((res: AxiosResponse) => { + if (res) axiosCanceler.removePending(res.config); + if (responseInterceptors && isFunction(responseInterceptors)) { + res = responseInterceptors(res); + } + return res; + }, undefined); + + // 响应错误处理 + if (responseInterceptorsCatch && isFunction(responseInterceptorsCatch)) { + this.instance.interceptors.response.use(undefined, (error) => responseInterceptorsCatch(error, this.instance)); + } + } + + /** + * 支持 FormData 请求格式 + * @param config + */ + supportFormData(config: AxiosRequestConfig) { + const headers = config.headers || (this.options.headers as AxiosRequestHeaders); + const contentType = headers?.['Content-Type'] || headers?.['content-type']; + + if ( + contentType !== ContentTypeEnum.FormURLEncoded || + !Reflect.has(config, 'data') || + config.method?.toUpperCase() === 'GET' + ) { + return config; + } + + return { + ...config, + data: stringify(config.data, { arrayFormat: 'brackets' }), + }; + } + + /** + * 支持 params 序列化 + * @param config + */ + supportParamsStringify(config: AxiosRequestConfig) { + const headers = config.headers || this.options.headers; + const contentType = headers?.['Content-Type'] || headers?.['content-type']; + + if (contentType === ContentTypeEnum.FormURLEncoded || !Reflect.has(config, 'params')) { + return config; + } + + return { + ...config, + paramsSerializer: (params: any) => stringify(params, { arrayFormat: 'brackets' }), + }; + } + + get(config: AxiosRequestConfig, options?: RequestOptions): Promise { + return this.request({ ...config, method: 'GET' }, options); + } + + post(config: AxiosRequestConfig, options?: RequestOptions): Promise { + return this.request({ ...config, method: 'POST' }, options); + } + + put(config: AxiosRequestConfig, options?: RequestOptions): Promise { + return this.request({ ...config, method: 'PUT' }, options); + } + + delete(config: AxiosRequestConfig, options?: RequestOptions): Promise { + return this.request({ ...config, method: 'DELETE' }, options); + } + + patch(config: AxiosRequestConfig, options?: RequestOptions): Promise { + return this.request({ ...config, method: 'PATCH' }, options); + } + + /** + * 上传文件封装 + * @param key 文件所属的key + * @param file 文件 + * @param config 请求配置 + * @param options + */ + upload(key: string, file: File, config: AxiosRequestConfig, options?: RequestOptions): Promise { + const params: FormData = config.params ?? new FormData(); + params.append(key, file); + + return this.request( + { + ...config, + method: 'POST', + headers: { + 'Content-Type': ContentTypeEnum.FormData, + }, + params, + }, + options, + ); + } + + /** + * 初始化节流/防抖 + * @private + */ + private setupThrottleDebounce() { + const { requestOptions } = this.options; + + if (requestOptions?.throttle?.delay) { + this.throttledRequest = throttle((config: AxiosRequestConfigRetry, options?: RequestOptions) => { + const resolver = this.pendingResolvers.shift(); + resolver?.resolve(this.synthesisRequest(config, options)); + }, requestOptions.throttle.delay); + } else if (requestOptions?.debounce?.delay) { + this.throttledRequest = debounce((config: AxiosRequestConfigRetry, options?: RequestOptions) => { + const resolver = this.pendingResolvers.shift(); + resolver?.resolve(this.synthesisRequest(config, options)); + }, requestOptions.debounce.delay); + } + } + + /** + * 请求封装 + * @param config + * @param options + */ + request(config: AxiosRequestConfigRetry, options?: RequestOptions): Promise { + const { requestOptions } = this.options; + + if (requestOptions?.throttle !== undefined && requestOptions?.debounce !== undefined) { + throw new Error('throttle and debounce cannot be set at the same time'); + } + + if (this.throttledRequest) { + return new Promise((resolve) => { + this.pendingResolvers.push({ resolve }); + this.throttledRequest!(config, options); + }); + } + + return this.synthesisRequest(config, options); + } + + /** + * 请求方法 + * @private + */ + private async synthesisRequest(config: AxiosRequestConfigRetry, options?: RequestOptions): Promise { + let conf: CreateAxiosOptions = cloneDeep(config); + const transform = this.getTransform(); + + const { requestOptions } = this.options; + + const opt: RequestOptions = { ...requestOptions, ...options }; + + const { beforeRequestHook, requestCatchHook, transformRequestHook } = transform || {}; + if (beforeRequestHook && isFunction(beforeRequestHook)) { + conf = beforeRequestHook(conf, opt); + } + conf.requestOptions = opt; + + conf = this.supportFormData(conf); + // 支持params数组参数格式化,因axios默认的toFormData即为brackets方式,无需配置paramsSerializer为qs,有需要可解除注释,参数参考qs文档 + // conf = this.supportParamsStringify(conf); + + return new Promise((resolve, reject) => { + this.instance + .request>(!config.retryCount ? conf : config) + .then((res: AxiosResponse) => { + if (transformRequestHook && isFunction(transformRequestHook)) { + try { + const ret = transformRequestHook(res, opt); + resolve(ret); + } catch (err) { + reject(err || new Error('请求错误!')); + } + return; + } + resolve(res as unknown as Promise); + }) + .catch((e: Error | AxiosError) => { + if (requestCatchHook && isFunction(requestCatchHook)) { + reject(requestCatchHook(e, opt)); + return; + } + if (axios.isAxiosError(e)) { + // 在这里重写Axios的错误信息 + } + reject(e); + }); + }); + } +} diff --git a/src/utils/request/AxiosCancel.ts b/src/utils/request/AxiosCancel.ts new file mode 100644 index 0000000..010b0da --- /dev/null +++ b/src/utils/request/AxiosCancel.ts @@ -0,0 +1,67 @@ +import type { AxiosRequestConfig, Canceler } from 'axios'; +import axios from 'axios'; +import isFunction from 'lodash/isFunction'; + +// 存储请求与取消令牌的键值对列表 +let pendingMap = new Map(); + +/** + * 获取请求Url + * @param config + */ +export const getPendingUrl = (config: AxiosRequestConfig) => [config.method, config.url].join('&'); + +/** + * @description 请求管理器 + */ +export class AxiosCanceler { + /** + * 添加请求到列表中 + * @param config + */ + addPending(config: AxiosRequestConfig) { + this.removePending(config); + const url = getPendingUrl(config); + config.cancelToken = + config.cancelToken || + new axios.CancelToken((cancel) => { + if (!pendingMap.has(url)) { + // 如果当前没有相同请求就添加 + pendingMap.set(url, cancel); + } + }); + } + + /** + * 移除现有的所有请求 + */ + removeAllPending() { + pendingMap.forEach((cancel) => { + if (cancel && isFunction(cancel)) cancel(); + }); + pendingMap.clear(); + } + + /** + * 移除指定请求 + * @param config + */ + removePending(config: AxiosRequestConfig) { + const url = getPendingUrl(config); + + if (pendingMap.has(url)) { + // If there is a current request identifier in pending, + // the current request needs to be cancelled and removed + const cancel = pendingMap.get(url); + if (cancel) cancel(url); + pendingMap.delete(url); + } + } + + /** + * 重置 + */ + reset() { + pendingMap = new Map(); + } +} diff --git a/src/utils/request/AxiosTransform.ts b/src/utils/request/AxiosTransform.ts new file mode 100644 index 0000000..aa1b343 --- /dev/null +++ b/src/utils/request/AxiosTransform.ts @@ -0,0 +1,63 @@ +import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; + +import type { RequestOptions, Result } from '@/types/axios'; + +/** + * @description 创建Axios实例配置 + */ +export interface CreateAxiosOptions extends AxiosRequestConfig { + /** + * 请求验证方案 + * + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes + */ + authenticationScheme?: string; + /** + * 请求数据处理 + */ + transform?: AxiosTransform; + /** + * 请求配置 + */ + requestOptions?: RequestOptions; +} + +/** + * Axios请求数据处理 抽象类 + */ +export abstract class AxiosTransform { + /** + * 请求前钩子 + */ + beforeRequestHook?: (_config: AxiosRequestConfig, _options: RequestOptions) => AxiosRequestConfig; + + /** + * 数据处理前钩子 + */ + transformRequestHook?: (_res: AxiosResponse, _options: RequestOptions) => T; + + /** + * 请求失败钩子 + */ + requestCatchHook?: (_e: Error | AxiosError, _options: RequestOptions) => Promise; + + /** + * 请求拦截器 + */ + requestInterceptors?: (_config: AxiosRequestConfig, _options: CreateAxiosOptions) => AxiosRequestConfig; + + /** + * 响应拦截器 + */ + responseInterceptors?: (_res: AxiosResponse) => AxiosResponse; + + /** + * 请求拦截器错误处理 + */ + requestInterceptorsCatch?: (_error: AxiosError) => void; + + /** + * 响应拦截器错误处理 + */ + responseInterceptorsCatch?: (_error: AxiosError, _instance: AxiosInstance) => void; +} diff --git a/src/utils/request/index.ts b/src/utils/request/index.ts new file mode 100644 index 0000000..95d54e7 --- /dev/null +++ b/src/utils/request/index.ts @@ -0,0 +1,206 @@ +// axios配置 可自行根据项目进行更改,只需更改该文件即可,其他文件可以不动 +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import isString from 'lodash/isString'; +import merge from 'lodash/merge'; + +import { ContentTypeEnum } from '@/constants'; +import { useUserStore } from '@/store'; + +import { VAxios } from './Axios'; +import type { AxiosTransform, CreateAxiosOptions } from './AxiosTransform'; +import { formatRequestDate, joinTimestamp, setObjToUrlParams } from './utils'; + +const env = import.meta.env.MODE || 'development'; + +// 如果是mock模式 或 没启用直连代理 就不配置host 会走本地Mock拦截 或 Vite 代理 +const host = env === 'mock' || import.meta.env.VITE_IS_REQUEST_PROXY !== 'true' ? '' : import.meta.env.VITE_API_URL; + +// 数据处理,方便区分多种处理方式 +const transform: AxiosTransform = { + // 处理请求数据。如果数据不是预期格式,可直接抛出错误 + transformRequestHook: (res, options) => { + const { isTransformResponse, isReturnNativeResponse } = options; + + // 如果204无内容直接返回 + const method = res.config.method?.toLowerCase(); + if (res.status === 204 && ['put', 'patch', 'delete'].includes(method ?? '')) { + return res; + } + + // 是否返回原生响应头 比如:需要获取响应头时使用该属性 + if (isReturnNativeResponse) { + return res; + } + // 不进行任何处理,直接返回 + // 用于页面代码可能需要直接获取code,data,message这些信息时开启 + if (!isTransformResponse) { + return res.data; + } + + // 错误的时候返回 + const { data } = res; + if (!data) { + throw new Error('请求接口错误'); + } + + // 这里 code为 后台统一的字段,需要在 types.ts内修改为项目自己的接口返回格式 + const { code } = data; + + // 这里逻辑可以根据项目进行修改 + const hasSuccess = data && code === 0; + if (hasSuccess) { + return data.data; + } + + throw new Error(`请求接口错误, 错误码: ${code}`); + }, + + // 请求前处理配置 + beforeRequestHook: (config, options) => { + const { apiUrl, isJoinPrefix, urlPrefix, joinParamsToUrl, formatDate, joinTime = true } = options; + + // 添加接口前缀 + if (isJoinPrefix && urlPrefix && isString(urlPrefix)) { + config.url = `${urlPrefix}${config.url}`; + } + + // 将baseUrl拼接 + if (apiUrl && isString(apiUrl)) { + config.url = `${apiUrl}${config.url}`; + } + const params = config.params || {}; + const data = config.data || false; + + if (formatDate && data && !isString(data)) { + formatRequestDate(data); + } + if (config.method?.toUpperCase() === 'GET') { + if (!isString(params)) { + // 给 get 请求加上时间戳参数,避免从缓存中拿数据。 + config.params = Object.assign(params || {}, joinTimestamp(joinTime, false)); + } else { + // 兼容restful风格 + config.url = `${config.url + params}${joinTimestamp(joinTime, true)}`; + config.params = undefined; + } + } else if (!isString(params)) { + if (formatDate) { + formatRequestDate(params); + } + if ( + Reflect.has(config, 'data') && + config.data && + (Object.keys(config.data).length > 0 || data instanceof FormData) + ) { + config.data = data; + config.params = params; + } else { + // 非GET请求如果没有提供data,则将params视为data + config.data = params; + config.params = undefined; + } + if (joinParamsToUrl) { + config.url = setObjToUrlParams(config.url as string, { ...config.params, ...config.data }); + } + } else { + // 兼容restful风格 + config.url += params; + config.params = undefined; + } + return config; + }, + + // 请求拦截器处理 + requestInterceptors: (config, options) => { + // 请求之前处理config + const userStore = useUserStore(); + const { token } = userStore; + + if (token && (config as Recordable)?.requestOptions?.withToken !== false) { + // jwt token + (config as Recordable).headers.Authorization = options.authenticationScheme + ? `${options.authenticationScheme} ${token}` + : token; + } + return config; + }, + + // 响应拦截器处理 + responseInterceptors: (res) => { + return res; + }, + + // 响应错误处理 + responseInterceptorsCatch: (error: any, instance: AxiosInstance) => { + const { config } = error; + if (!config || !config.requestOptions.retry) return Promise.reject(error); + + config.retryCount = config.retryCount || 0; + + if (config.retryCount >= config.requestOptions.retry.count) return Promise.reject(error); + + config.retryCount += 1; + + const backoff = new Promise((resolve) => { + setTimeout(() => { + resolve(config); + }, config.requestOptions.retry.delay || 1); + }); + config.headers = { ...config.headers, 'Content-Type': ContentTypeEnum.Json }; + return backoff.then((config) => instance.request(config as AxiosRequestConfig)); + }, +}; + +function createAxios(opt?: Partial) { + return new VAxios( + merge( + { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes + // 例如: authenticationScheme: 'Bearer' + authenticationScheme: '', + // 超时 + timeout: 10 * 1000, + // 携带Cookie + withCredentials: true, + // 头信息 + headers: { 'Content-Type': ContentTypeEnum.Json }, + // 数据处理方式 + transform, + // 配置项,下面的选项都可以在独立的接口请求中覆盖 + requestOptions: { + // 接口地址 + apiUrl: host, + // 是否自动添加接口前缀 + isJoinPrefix: true, + // 接口前缀 + // 例如: https://www.baidu.com/api + // urlPrefix: '/api' + urlPrefix: import.meta.env.VITE_API_URL_PREFIX, + // 是否返回原生响应头 比如:需要获取响应头时使用该属性 + isReturnNativeResponse: false, + // 需要对返回数据进行处理 + isTransformResponse: true, + // post请求的时候添加参数到url + joinParamsToUrl: false, + // 格式化提交参数时间 + formatDate: true, + // 是否加入时间戳 + joinTime: true, + // 是否忽略请求取消令牌 + // 如果启用,则重复请求时不进行处理 + // 如果禁用,则重复请求时会取消当前请求 + ignoreCancelToken: true, + // 是否携带token + withToken: true, + // 重试 + retry: { + count: 3, + delay: 1000, + }, + }, + }, + opt || {}, + ), + ); +} +export const request = createAxios(); diff --git a/src/utils/request/utils.ts b/src/utils/request/utils.ts new file mode 100644 index 0000000..03ab3d6 --- /dev/null +++ b/src/utils/request/utils.ts @@ -0,0 +1,53 @@ +import isObject from 'lodash/isObject'; +import isString from 'lodash/isString'; + +const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +export function joinTimestamp(_join: boolean, _restful: T): T extends true ? string : object; + +export function joinTimestamp(join: boolean, restful = false): string | object { + if (!join) { + return restful ? '' : {}; + } + const now = Date.now(); + if (restful) { + return `?_t=${now}`; + } + return { _t: now }; +} + +// 格式化提交参数时间 +export function formatRequestDate(params: Recordable) { + if (Object.prototype.toString.call(params) !== '[object Object]') { + return; + } + + for (const key in params) { + if (params[key] && params[key]._isAMomentObject) { + params[key] = params[key].format(DATE_TIME_FORMAT); + } + if (isString(key)) { + const value = params[key]; + if (value) { + try { + params[key] = isString(value) ? value.trim() : value; + } catch (error: any) { + throw new Error(error); + } + } + } + if (isObject(params[key])) { + formatRequestDate(params[key]); + } + } +} + +// 将对象转为Url参数 +export function setObjToUrlParams(baseUrl: string, obj: { [index: string]: any }): string { + let parameters = ''; + for (const key in obj) { + parameters += `${key}=${encodeURIComponent(obj[key])}&`; + } + parameters = parameters.replace(/&$/, ''); + return /\?$/.test(baseUrl) ? baseUrl + parameters : baseUrl.replace(/\/?$/, '?') + parameters; +} diff --git a/src/utils/route/constant.ts b/src/utils/route/constant.ts new file mode 100644 index 0000000..a3ec01c --- /dev/null +++ b/src/utils/route/constant.ts @@ -0,0 +1,14 @@ +export const LAYOUT = () => import('@/layouts/index.vue'); +export const BLANK_LAYOUT = () => import('@/layouts/blank.vue'); +export const IFRAME = () => import('@/layouts/components/FrameBlank.vue'); +export const EXCEPTION_COMPONENT = () => import('@/pages/result/500/index.vue'); +export const PARENT_LAYOUT = () => + new Promise((resolve) => { + resolve({ name: 'ParentLayout' }); + }); + +export const PAGE_NOT_FOUND_ROUTE = { + path: '/:w+', + name: '404Page', + redirect: '/result/404', +}; diff --git a/src/utils/route/index.ts b/src/utils/route/index.ts new file mode 100644 index 0000000..d0e2d90 --- /dev/null +++ b/src/utils/route/index.ts @@ -0,0 +1,121 @@ +import cloneDeep from 'lodash/cloneDeep'; + +import type { RouteItem } from '@/api/model/permissionModel'; +import { + BLANK_LAYOUT, + EXCEPTION_COMPONENT, + IFRAME, + LAYOUT, + PAGE_NOT_FOUND_ROUTE, + PARENT_LAYOUT, +} from '@/utils/route/constant'; + +// 动态从包内引入单个Icon,如果没有网络环境可以使用这种方式 但是会导致产物存在多个chunk +// const iconsPath = import.meta.glob('../../../node_modules/tdesign-icons-vue-next/esm/components/*.js'); + +// async function getMenuIcon(iconName: string): Promise { +// const RenderIcon = iconsPath[`../../../node_modules/tdesign-icons-vue-next/esm/components/${iconName}.js`]; + +// const Icon = await RenderIcon(); +// return shallowRef(Icon.default); +// } + +const LayoutMap = new Map Promise>(); + +LayoutMap.set('LAYOUT', LAYOUT); +LayoutMap.set('BLANK', BLANK_LAYOUT); +LayoutMap.set('IFRAME', IFRAME); + +let dynamicViewsModules: Record Promise>; + +// 动态引入路由组件 +function asyncImportRoute(routes: RouteItem[] | undefined) { + dynamicViewsModules = dynamicViewsModules || import.meta.glob('../../pages/**/*.vue'); + if (!routes) return; + + routes.forEach(async (item) => { + const { component, name } = item; + const { children } = item; + + if (component) { + const layoutFound = LayoutMap.get(component.toUpperCase()); + if (layoutFound) { + item.component = layoutFound; + } else { + item.component = dynamicImport(dynamicViewsModules, component); + } + } else if (name) { + item.component = PARENT_LAYOUT(); + } + + // 动态从包内引入单个Icon,如果没有网络环境可以使用这种方式 但是会导致产物存在多个chunk + // if (item.meta.icon) item.meta.icon = await getMenuIcon(item.meta.icon); + + children && asyncImportRoute(children); + }); +} + +function dynamicImport(dynamicViewsModules: Record Promise>, component: string) { + const keys = Object.keys(dynamicViewsModules); + const matchKeys = keys.filter((key) => { + const k = key.replace('../../pages', ''); + const startFlag = component.startsWith('/'); + const endFlag = component.endsWith('.vue') || component.endsWith('.tsx'); + const startIndex = startFlag ? 0 : 1; + const lastIndex = endFlag ? k.length : k.lastIndexOf('.'); + return k.substring(startIndex, lastIndex) === component; + }); + if (matchKeys?.length === 1) { + const matchKey = matchKeys[0]; + return dynamicViewsModules[matchKey]; + } + if (matchKeys?.length > 1) { + throw new Error( + 'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure', + ); + } else { + console.warn(`Can't find ${component} in pages folder`); + } + return EXCEPTION_COMPONENT; +} + +// 将背景对象变成路由对象 +export function transformObjectToRoute(routeList: RouteItem[]): T[] { + routeList.forEach(async (route) => { + const component = route.component as string; + + if (component) { + if (component.toUpperCase() === 'LAYOUT') { + route.component = LayoutMap.get(component.toUpperCase()); + } else { + route.children = [cloneDeep(route)]; + route.component = LAYOUT; + route.name = `${route.name}Parent`; + route.path = ''; + route.meta = route.meta || {}; + } + } else { + throw new Error('component is undefined'); + } + + route.children && asyncImportRoute(route.children); + + // 动态从包内引入单个Icon,如果没有网络环境可以使用这种方式 但是会导致产物存在多个chunk + // if (route.meta.icon) + // route.meta.icon = await getMenuIcon(route.meta.icon); + }); + + return [PAGE_NOT_FOUND_ROUTE, ...routeList] as unknown as T[]; +} + +// 统一 path 编解码 +export function normalizePath(path: string): string { + if (!path || !path.includes('%')) return path; + + // 遇到非合规 % 参数会抛出错误 + try { + return decodeURIComponent(path); + } catch { + return path; + } +} diff --git a/stylelint.config.js b/stylelint.config.js new file mode 100644 index 0000000..5d66549 --- /dev/null +++ b/stylelint.config.js @@ -0,0 +1,30 @@ +export default { + defaultSeverity: 'error', + extends: ['stylelint-config-standard'], + plugins: ['stylelint-order'], + rules: { + 'no-descending-specificity': null, + 'import-notation': 'string', + 'no-empty-source': null, + 'custom-property-pattern': null, + 'selector-class-pattern': null, + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['deep'], + }, + ], + 'media-query-no-invalid': null, // 官方表示此规则应当仅对于原生CSS启用,对于预处理器(Less)不应启用 + 'declaration-property-value-no-unknown': null, // 官方表示此规则应当仅对于原生CSS启用,对于预处理器(Less)不应启用 + }, + overrides: [ + { + files: ['**/*.html', '**/*.vue'], + customSyntax: 'postcss-html', + }, + { + files: ['**/*.less'], + customSyntax: 'postcss-less', + }, + ], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f83593f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "ignoreDeprecations": "6.0", + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "vue", + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "lib": ["esnext", "dom"], + "types": ["vite/client", "tdesign-vue-next/global"], + "noEmit": true, + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + }, + "noImplicitAny": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "noImplicitThis": true, + "alwaysStrict": true + }, + "include": [ + "**/*.ts", + "src/**/*.d.ts", + "src/**/*.tsx", + "src/**/*.vue" + ], + "compileOnSave": false +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f3ebe63 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,118 @@ +import path from 'node:path'; + +import vue from '@vitejs/plugin-vue'; +import vueJsx from '@vitejs/plugin-vue-jsx'; +import type { ConfigEnv, UserConfig } from 'vite'; +import { loadEnv } from 'vite'; +import compression from 'vite-plugin-compression'; +import { viteMockServe } from 'vite-plugin-mock'; +import svgLoader from 'vite-svg-loader'; + +const CWD = process.cwd(); + +// https://vitejs.dev/config/ +export default ({ mode }: ConfigEnv): UserConfig => { + const { VITE_BASE_URL, VITE_API_URL_PREFIX } = loadEnv(mode, CWD); + return { + base: VITE_BASE_URL, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + + css: { + preprocessorOptions: { + less: { + modifyVars: { + hack: `true; @import (reference) "${path.resolve('src/style/variables.less')}";`, + }, + math: 'strict', + javascriptEnabled: true, + }, + }, + }, + + plugins: [ + vue(), + vueJsx(), + viteMockServe({ + mockPath: 'mock', + enable: true, + }), + svgLoader(), + compression({ + algorithm: 'gzip', // 压缩算法 + filter: /\.(html|js|css|json|txt|ico|svg|png)(\?.*)?$/i, // 需要压缩的文件 + ext: '.gz', // 压缩后缀 + threshold: 4096, // 大于 4KB 的文件将会被压缩 + deleteOriginFile: false, // 压缩后删除对应的源文件 + compressionOptions: { + level: 9, // 压缩级别,1~9,压缩级别(数值)越高,压缩的越狠 + }, + }), + ], + + server: { + port: 3002, + host: '0.0.0.0', + allowedHosts: true, + proxy: { + [VITE_API_URL_PREFIX]: 'http://127.0.0.1:3000/', + }, + }, + + // https://github.com/vueuse/vueuse/issues/5387#issuecomment-4734186040 + build: { + chunkSizeWarningLimit: 1000, + rolldownOptions: { + onLog(level, log, defaultHandler) { + if (log.code === 'INVALID_ANNOTATION') return null; + else defaultHandler(level, log); + }, + // [name] + output: { + // 代码块文件名 + chunkFileNames: 'assets/js/xiaohh-[hash].js', + // 入口文件名 + entryFileNames: 'assets/xiaohh-[hash].js', + assetFileNames(assetInfo) { + // 文件名 + const fileName = assetInfo.names[0]; + if (fileName.endsWith('.svg') || fileName.endsWith('.png') || fileName.endsWith('.jpg')) { + // 判断如果是 svg, png, jpg 等图片文件,则放入 assets/img 文件夹当中 + return 'assets/img/xiaohh-[hash].[ext]'; + } else if (fileName.endsWith('.css')) { + // 如果是 css 文件,则放入 assets/css 文件夹当中 + return 'assets/css/xiaohh-[hash].[ext]'; + } else { + // 如果是其他文件,则直接放入 assets 文件夹当中 + return 'assets/xiaohh-[hash].[ext]'; + } + }, + manualChunks(id) { + if (id.includes('node_modules')) { + // node_modules 当中不同依赖打包成不同的文件 + return id.split('node_modules/')[1].split('/')[0]; + } else if (id.endsWith('.svg')) { + // 如果是 svg 文件打包成 js 文件,则每个 svg 文件单独一个 js 文件 + return ( + id + .split('/') + .pop() + ?.replace(/\.svg$/i, '') || '' + ); + } + }, + }, + onwarn(warning, defaultHandler) { + // @vueuse/core 中的 PURE 注释在 Rolldown 中位置无效,忽略 + if (warning.code === 'INVALID_ANNOTATION' && warning.id?.includes('@vueuse/core')) { + return; + } + defaultHandler(warning); + }, + }, + }, + }; +};