diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 00000000..4c0cdf3f --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,25 @@ +{ + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": false, + "commitConvention": "angular", + "contributors": [ + { + "login": "yunkuangao", + "name": "Cloud", + "avatar_url": "https://avatars.githubusercontent.com/u/40163747?v=4", + "profile": "https://heartofyun.com", + "contributions": [ + "tool" + ] + } + ], + "contributorsPerLine": 7, + "skipCi": true, + "repoType": "github", + "repoHost": "https://github.com", + "projectName": "xiaodaigua-ray.github.io", + "projectOwner": "XiaoDaiGua-Ray" +} diff --git a/.depcheckrc b/.depcheckrc new file mode 100644 index 00000000..8c906e3d --- /dev/null +++ b/.depcheckrc @@ -0,0 +1,2 @@ +ignores: [ "eslint", "babel-*", "@use-*/**", "@use-*" ] +skip-missing: true \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 00000000..d48e91ea --- /dev/null +++ b/.env.development @@ -0,0 +1,10 @@ +#开发环境 +NODE_ENV = 'development' + +VITE_APP_URL = '/api' + +# office 服务代理地址 +VITE_APP_OFFICE_PROXY_URL = '/office/' + +# office 脚本地址(用于动态创建脚本标签) +VITE_APP_OFFICE_SCRIPT_URL = 'https://office.yka.one/web-apps/apps/api/documents/api.js' \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 00000000..d4ddf677 --- /dev/null +++ b/.env.production @@ -0,0 +1,10 @@ +#生产环境 +NODE_ENV = 'production' + +VITE_APP_URL = '/' + +# office 服务代理地址 +VITE_APP_OFFICE_PROXY_URL = 'https://office.yka.one/' + +# office 脚本地址(用于动态创建脚本标签) +VITE_APP_OFFICE_SCRIPT_URL = 'https://office.yka.one/web-apps/apps/api/documents/api.js' \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..91ceefa8 --- /dev/null +++ b/.env.test @@ -0,0 +1,10 @@ +#测试环境 +NODE_ENV = 'test' + +VITE_APP_URL = 'https://testray.yka.moe/doc-json/' + +# office 服务代理地址 +VITE_APP_OFFICE_PROXY_URL = 'https://office.yka.one/' + +# office 脚本地址(用于动态创建脚本标签) +VITE_APP_OFFICE_SCRIPT_URL = 'https://office.yka.one/web-apps/apps/api/documents/api.js' \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..50d3d1a3 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,15 @@ +dist/* +node_modules/* +auto-imports.d.ts +components.d.ts +.gitignore +.vscode +public +yarn.* +vite-env.* +.prettierrc.* +visualizer.* +visualizer.html +.env.* +src/locales/lang +.depcheckrc \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..2e4aac16 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,198 @@ +/* eslint-env node */ +module.exports = { + root: true, + env: { + browser: true, + node: true, + }, + extends: [ + 'eslint-config-prettier', + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:vue/vue3-recommended', + 'plugin:vue/vue3-essential', + 'plugin:prettier/recommended', + 'prettier', + ], + parser: 'vue-eslint-parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + parser: '@typescript-eslint/parser', + ecmaFeatures: { + jsx: true, + tsx: true, + }, + }, + plugins: ['vue', '@typescript-eslint', 'prettier'], + globals: { + defineProps: 'readonly', + defineEmits: 'readonly', + defineExpose: 'readonly', + withDefaults: 'readonly', + }, + rules: { + '@typescript-eslint/no-explicit-any': [ + 'error', + { + ignoreRestArgs: true, + }, + ], + 'prettier/prettier': 'error', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { disallowTypeAnnotations: false }, + ], // 强制导入类型显示标注 `import type xxx from 'xxx'` + '@typescript-eslint/no-empty-interface': [ + 'error', + { + allowSingleExtends: true, + }, + ], + 'accessor-pairs': 2, // 强制同时存在 `get` 与 `set` + 'constructor-super': 0, // 强制子类构造函数中使用 `super` 调用父类的构造函数 + 'default-case': 2, // `switch` 中强制含有 `default` + eqeqeq: [2, 'allow-null'], // 强制使用严格判断 `===` + 'no-alert': 0, // 禁止使用 `alert`、`confirm` + 'no-array-constructor': 2, // 禁止使用数组构造器 + 'no-bitwise': 0, // 禁止使用按位运算符 + 'no-caller': 1, // 禁止使用 `arguments.caller`、`arguments.callee` + 'no-catch-shadow': 2, // 禁止 `catch` 子句参数与外部作用域变量同名 + 'no-class-assign': 2, // 禁止给类赋值 + 'no-cond-assign': 2, // 禁止在条件表达式中使用赋值语句 + 'no-const-assign': 2, // 禁止修改 `const` 声明的变量 + 'no-constant-condition': 2, // 禁止在条件中使用常量表达式 `if(true)`、`if(1)` + 'no-dupe-keys': 2, // 在创建对象字面量时不允许 `key` 重复 + 'no-dupe-args': 2, // 函数参数不能重复 + 'no-duplicate-case': 2, // `switch` 中的 `case` 标签不能重复 + 'no-eval': 1, // 禁止使用 `eval` + 'no-ex-assign': 2, // 禁止给 `catch` 语句中的异常参数赋值 + 'no-extend-native': 2, // 禁止扩展 `native` 对象 + 'no-extra-bind': 2, // 禁止不必要的函数绑定 + 'no-extra-boolean-cast': [ + 'error', + { + enforceForLogicalOperands: true, + }, + ], // 禁止不必要的 `bool` 转换 + 'no-extra-parens': 0, // 禁止非必要的括号 + semi: ['error', 'never', { beforeStatementContinuationChars: 'always' }], + 'no-fallthrough': 1, // 禁止 `switch` 穿透 + 'no-func-assign': 2, // 禁止重复的函数声明 + 'no-implicit-coercion': [ + 'error', + { + allow: ['!!', '~'], + }, + ], // 禁止隐式转换 + 'no-implied-eval': 2, // 禁止使用隐式 `eval` + 'no-invalid-regexp': 2, // 禁止无效的正则表达式 + 'no-invalid-this': 2, // 禁止无效的 `this` + 'no-irregular-whitespace': 2, // 禁止含有不合法空格 + 'no-iterator': 2, // 禁止使用 `__iterator__ ` 属性 + 'no-label-var': 2, // `label` 名不能与 `var` 声明的变量名相同 + 'no-labels': 2, // 禁止标签声明 + 'no-lone-blocks': 2, // 禁止不必要的嵌套块 + 'no-multi-spaces': 1, // 禁止使用多余的空格 + 'no-multiple-empty-lines': [1, { max: 2 }], // 空行最多不能超过 `2` 行 + 'no-new-func': 1, // 禁止使用 `new Function` + 'no-new-object': 2, // 禁止使用 `new Object` + 'no-new-require': 2, // 禁止使用 `new require` + 'no-sparse-arrays': 2, // 禁止稀疏数组 + 'no-trailing-spaces': 1, // 一行结束后面不要有空格 + 'no-unreachable': 2, // 禁止有无法执行的代码 + 'no-unused-expressions': [ + 'error', + { + allowShortCircuit: true, + allowTernary: true, + allowTaggedTemplates: true, + enforceForJSX: true, + }, + ], // 禁止无用的表达式 + 'no-useless-call': 2, // 禁止不必要的 `call` 和 `apply` + 'no-var': 'error', // 禁用 `var` + 'no-with': 2, // 禁用 `with` + 'no-undef': 0, + 'use-isnan': 2, // 强制使用 isNaN 判断 NaN + 'no-multi-assign': 2, // 禁止连续声明变量 + 'prefer-arrow-callback': 2, // 强制使用箭头函数作为回调 + curly: ['error', 'all'], + 'vue/multi-word-component-names': [ + 'error', + { + ignores: [], + }, + ], + 'vue/no-use-v-if-with-v-for': [ + 'error', + { + allowUsingIterationVar: false, + }, + ], + 'vue/require-v-for-key': ['error'], + 'vue/require-valid-default-prop': ['error'], + 'no-use-before-define': [ + 'error', + { + functions: true, + classes: true, + variables: false, + allowNamedExports: false, + }, + ], + 'vue/component-definition-name-casing': ['error', 'PascalCase'], + 'vue/html-closing-bracket-newline': [ + 'error', + { + singleline: 'never', + multiline: 'always', + }, + ], + 'vue/v-on-event-hyphenation': ['error', 'never'], + 'vue/component-tags-order': [ + 'error', + { + order: ['template', 'script', 'style'], + }, + ], + 'vue/no-v-html': ['error'], + 'vue/no-v-text': ['error'], + 'vue/component-api-style': [ + 'error', + ['script-setup', 'composition', 'composition-vue2'], + ], + 'vue/component-name-in-template-casing': [ + 'error', + 'PascalCase', + { + registeredComponentsOnly: true, + globals: ['RouterView'], + }, + ], + 'vue/no-unused-refs': ['error'], + 'vue/prop-name-casing': ['error', 'camelCase'], + 'vue/component-options-name-casing': ['error', 'camelCase'], + 'vue/attribute-hyphenation': [ + 'error', + 'never', + { + ignore: [], + }, + ], + 'vue/no-restricted-static-attribute': [ + 'error', + { + key: 'key', + message: 'Disallow using key as a custom attribute', + }, + ], + }, +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd84ea78 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3dc6026c --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +yarn.lock* + +node_modules +dist-ssr +dist +dist/ +*.local +visualizer.* +components.d.ts +auto-imports.d.ts + +# Editor directories and files +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +yarn-*.* +yarn.* +pnpm.* +pnpm-lock.yaml diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 00000000..9cdbd7af --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged --allow-empty "$1" \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..9cdbd7af --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged --allow-empty "$1" \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..2a1a040a --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +yarn.lock +yarn-error.log +visualizer.html \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..c6df65bc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +dist/* +node_modules/* +auto-imports.d.ts +components.d.ts +.gitignore +public +yarn.* +vite-env.* +.prettierrc.* +visualizer.* +visualizer.html +.env.* \ No newline at end of file diff --git a/.prettierrc.cjs b/.prettierrc.cjs new file mode 100644 index 00000000..23cc440e --- /dev/null +++ b/.prettierrc.cjs @@ -0,0 +1,20 @@ +module.exports = { + printWidth: 80, // 一行最多 `80` 字符 + tabWidth: 2, // 使用 `2` 个空格缩进 + useTabs: false, // 不使用缩进符, 而使用空格 + semi: false, // 行尾不需要有分号 + singleQuote: true, // 使用单引号 + quoteProps: 'as-needed', // 对象的 `key` 仅在必要时用引号 + jsxSingleQuote: false, // `jsx` 不使用单引号, 而使用双引号 + trailingComma: 'all', // 尾随逗号 + bracketSpacing: true, // 大括号内的首尾需要空格 + jsxBracketSameLine: false, // `jsx` 标签的反尖括号需要换行 + arrowParens: 'always', // 箭头函数, 只有一个参数的时候, 也需要括号 + rangeStart: 0, // 每个文件格式化的范围是文件的全部内容 + rangeEnd: Infinity, + requirePragma: false, // 不需要写文件开头的 `@prettier` + insertPragma: false, // 不需要自动在文件开头插入 `@prettier` + proseWrap: 'preserve', // 使用默认的折行标准 + htmlWhitespaceSensitivity: 'css', // 根据显示样式决定 `html` 要不要折行 + endOfLine: 'lf', // 换行符使用 `lf` +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..62a1b221 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["vue.volar", "lokalise.i18n-ally"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..0fa44d74 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "i18n-ally.localesPaths": ["src/locales/lang"], + "i18n-ally.keystyle": "nested", + "i18n-ally.sortKeys": true, + "i18n-ally.namespace": true, + "i18n-ally.pathMatcher": "{locale}/{namespaces}.{ext}", + "i18n-ally.enabledParsers": ["json"], + "i18n-ally.sourceLanguage": "zh-CN", + "i18n-ally.displayLanguage": "zh-CN", + "i18n-ally.enabledFrameworks": ["vue", "react"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d19d8807 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,308 @@ +# CHANGE LOG + +## 4.0.2 + +### Feats + +- 新增平级路由配置(router meta)配置项,sameLevel 允许你将子路由标记为平级模式,跳转时不会出发菜单、标签页更新,仅会更新面包屑 +- 修改路由菜单显示、隐藏逻辑,现在仅会针对权限的验证匹配选择是否加入菜单列表中 +- 更新 setupAppMenu 方法触发时机(Layout => menu store),现在将在 pinia menu store 初始化时触发 App Menu 更新 +- 更新了 utils 包中的一些方法,进行了一些重写和重命名 +- GlobalSearch 组件支持上下按键切换、回车键选择 +- 整合 router 模块的一些包,让它看起来更合理一点 +- 剔除 styles 包中一些不合理的样式模块 +- 补充了一些注释与说明文档 + +### Fixes + +- 修复不能正确渲染浏览器标题问题 +- 修复初始化模板菜单函数与菜单更新函数重复执行一些方法的问题 +- 修复指令示例变量绑定错误导致示例错误问题 +- 修复路由白名单失效 bug + +## 4.0.1 + +### Feats + +- 更改自定义路由暴露形式(由变量暴露改为方法获取) +- 模板所有方法进行检查,重命名部分方法(使其更加贴切其逻辑) +- 部分逻辑进行重写,使代码更容易阅读与维护 +- 模板类型进一步完善 + +### Fixes + +- 修复了内存高占用问题(路由模块) +- 修复类型导入错误问题 + +## 4.0.0 + +### Feats + +- 重构 types 包设计,现在的类型包更加清晰 +- 重构 utils 包设计,该包下的所有 hook 提供了更加友好的类型提示 +- RayIframe 组件新增 lazy 属性 +- 新增 v-disabled 指令 +- demo 页面展示优化 + +### Fixes + +- 修复一些已知的 bug + +### 补充 + +> 这次花了一点时间,将模板进行重新梳理,进行了一些很大的破坏性更新改动。核心重点是 types 包与 utils 包的重大更新。不过只是做了初步的一些大方向的更新,后续的细节更新还在继续。。。 + +## 3.3.7 + +### Feats + +- 新增全局指令(目前仅有:v-copy、v-debounce、v-throttle) + +### Fixes + +- 修复错误的插件命名,导致项目构建失败(viteComponents) + +## 3.3.6 + +### Feats + +- 重写 axios interceptor 方法。现在逻辑更加清晰,并且支持请求错误、响应错误处理。补充了两个工具函数 +- MenuTag 支持动态更新所在位置 +- 修复了鉴权方法的 bug +- 更新了 router permission 方法(路由守卫) +- 补充了一些模块文档 +- 搜索支持以菜单模块的 icon 进行渲染,如果为空则以 icon table 默认填充 +- 重写锁屏功能,现在将锁屏逻辑与解锁逻辑拆分为两个组件 + +### Fixes + +- 修复选中所搜结果后,菜单不能默认展开 bug + +### 补充 + +> 文档拖欠太多了,我补不回来了,就。。。算了吧,我在每个关键模块补充了对应的 md 说明文档,凑合一下吧。真希望有一个好心人帮补充文档。 + +## 3.3.5 + +### Feats + +- Router Meta 属性支持自定义图标,不再局限于 RayIcon,支持自定义图标 +- 更改部分组件默认值,默认值统一为 `null` +- 调整 validRole 方法逻辑,将该方法以前逻辑拆分为 validRole 与 validMenuItemShow 两个方法 +- 新增使用手册 + +### 补充 + +> 由于文档已经拖更很久,所以补充一个使用手册。最近太忙了,一直忙着更新完善模板本身,文档的事情暂时没有时间去维护更新,所以与模板断层太久。。。后续有时间肯定会补上!!! + +## 3.3.4 + +### Feats + +- 新增 RayIframe 组件 +- 同步更新 `naive-ui` 版本至最新版本(2.34.3 => 2.34.4) +- 支持更多 appConfig 配置 + +### TODO + +- MenuTag: 切换页面时, 同步更新该标签的所在位置 + +## 3.3.3 + +### Feats + +- 新增五个计算方法(解决精度问题) +- 解决一些小问题 + +## 3.3.1 + +### Feats + +- 新增 useAppTheme sass 方法 + +```ts +useAppTheme key 类型: 'dark' | 'light' +``` + +```scss +// 暗色主题 +.demo--dark { + @include useAppTheme('dark') { + color: #ffffff; + } +} +// 明亮主题 +.demo--light { + @include useAppTheme('light') { + color: #000000; + } +} +``` + +- 一些细节优化 +- axios 拦截器与 axios instance 进行独立(现在不再 instance.ts 文件中编写拦截器相关逻辑),拦截器逻辑放在 inject 包中 +- 一些 bug 修复 + +## 3.3.0 + +### 特征 + +- 取消 RootRoute 属性暴露全局 +- 新增 Route Meta keepAlive 配置开启页面缓存(可以在 AppConfig APP_KEEP_ALIVE 中进行缓存的配置管理) +- 回退使用自动导入路由模块方式,具体使用方法查看 [路由配置](https://github.com/XiaoDaiGua-Ray/ray-template/blob/main/src/router/README.md) +- 新增 Route Meta order 配置,配置菜单顺序 +- 新增 useVueRouter 方法,让你在 setup 环境之外使用 router hook +- 补充引入了一些 eslint 规则 +- 支持更多 appConfig 配置 + +### 补充 + +- 后续该模板还会持续维护,会尽可能多的支持更多业务场景 +- 最近破坏性更新很多,发布比较频繁,后续应该不会有这么大的破坏性更新。核心重点会放在模板整体的健壮性、可维护性上 +- 未来希望模板拆分为一个高拓展性的工程,积木式管理项目,让项目模块之间尽可能的解耦。让模板有更好的拓展性,让你在使用时,可以根据自身业务需求进行拓展(当然,我希望你能以项目的基本维护原则延续) + +## 3.2.3 + +### 特征 + +- 新增锁屏功能(值得注意的是,锁屏解锁后会刷新当前 RouterView 区域,因为在处于锁屏状态时,会自动销毁所有的操作页面。可以理解为是一个 v-if 操作行为) +- 新增 dayjs hook,支持国际化与切换 +- 支持更多 appConfig 配置 +- 调整 setupAppRoute 触发时机(现在会在 layout 渲染阶段触发) +- 补充了新的组件分包 AppComponents,存放该系统的一些组件(会与系统进行一些深度绑定,例如 AppAvatar 组件依赖系统数据) + +### 补充 + +- 锁屏功能的设计并不理想,后期会进行破坏性更新。锁屏触发条件与管理方式目前并不理想,管理有点混乱 +- 后期会考虑补充 keepAlive 功能。目前没有实现是因为该功能实现的话,需要将所有路由提升为顶层路由(这是 KeepAlive 组件限制),目前并未实现该功能。后期会在权衡后增加该功能,实现时会在 RayTransitionComponent 进行拓展补充 + +## 3.2.2 + +### 特征 + +- 移除 amfe-flexible 插件,改用为 postcss-px-to-viewport 作为适配插件 +- 支持更多 appConfig 配置 + +## 3.2.1 + +### 特征 + +- 调整系统文件分包,现在结构更加合理、更加清晰 +- 新增 src/appConfig 配置入口,配置系统(还在持续补充中...) +- vite 版本更新到 4.3.8 + +## 3.1.8 + +### Fixes + +- 修复路由切换不能复位容器位置问题(让可视区域置顶) + +### Feats + +- 新增 useI18n hook 方法 +- 手动补充 AppRouteRecordRaw、AppRouteMeta 类型 +- 重新拆分 Layout 入口文件 +- 重新指定组件暴露方法、属性 +- 修改国际化管理方式,现在支持自动合并管理与结合 i18n-ally 使用。并且支持 unplugin-vue-i18n 构建,提高性能 + +## 3.1.7 + +### Fixes + +- 修复默认获取容器可视区域高度问题 +- 修复登陆页虚线高度问题 + +### Feats + +- 修改 Menu 菜单过滤逻辑,现在如果权限不匹配或者设置了 hidden 属性,则会被过滤掉 +- 移除 $activedColor 全局 sass 变量,使用 --ray-theme-primary-color 替代 +- 新增路由菜单检索功能 +- 移除 App.tsx 中同步主题方法,改为使用 cfg 配置并且使用 ejs 注入 +- 移除 MenuTag 默认主题色,现在会以当前主题色为主色 + +## 3.1.6 + +### Fixes + +- 修复移动端登陆页显示问题 +- 改进了一些方法逻辑的问题 +- 修改移动端自适应配置方案(现在使用 postcss-px-to-viewport),默认不启用 +- 修复 RayTable 实例方法暴露错误 +- 修复 sideBarLogo.icon 为空时警告问题,现在未配置该属性则不会渲染图标 +- 修复 RayTable 演示页面 action 方法失效问题 + +### Feats + +- 新增加载动画 +- 现在可以直接配置首屏加载动画一些信息(cfg.ts) +- 新增对于 ejs 支持 +- 补充一些细节注释 +- 新增 RayChart 组件 loading、loadingOptions 属性配置 +- 新增反转色模式 +- 修改 Menu 菜单过滤逻辑,现在如果权限不匹配或者设置了 hidden 属性,则会被过滤掉 + +## 3.1.5 + +### Fixes + +- 配置 `tsconfig.json` 中 `ignoreDeprecations` 属性,消除 `ts5.0` 破坏性配置更新警告 + +### Feats + +- 基于 `onlyoffice` 新增 `Office` 功能(待完成...) +- 重写 `AxiosInstance` 类型 +- `src/types` 分包更加清晰 +- 将主色调同步至 `body`,默认同步 `cfg.primaryColor` 值 +- 登陆页一些修改(现在支持简单的响应式) +- 将一些设置型功能抽离为组件 +- 调整同步主题色执行时机 + +## 3.1.4 + +### Fixes + +- 修复主题色切换后,点击、鼠标滑入主题未被修改问题 +- 修复 menu store 菜单切换可能会重复执行问题 + +### Feats + +- 补充 MenuTag 标签页功能,现在支持丰富的关闭操作与右键菜单激活操作菜单功能 +- 新增配置全局重定向地址配置(详情见:[cfg](https://github.com/XiaoDaiGua-Ray/xiaodaigua-ray.github.io/blob/main/cfg.ts)) +- 补充了一些不值一提的小东西 + +## 3.1.3 + +### Fixes + +- 修复菜单栏、标签页栏 border 显示问题 + +### Feats + +- RayTable 组件新增全屏、尺寸调整功能 +- 新增 css 预处理全局注入辅助函数。详情看 [mixinCSS](https://github.com/XiaoDaiGua-Ray/xiaodaigua-ray.github.io/blob/main/cfg.ts) +- RayTable 组件部分提示文案修改 +- body 新增当前主题色 class 标识(dark: ray-template--dark,light: ray-template--light),便捷主题切换配置 + +## 3.1.2 + +### Fixes + +- 修复 DatePicker 组件国际化部分失效问题 + +### Feats + +- 修改 demo 页面展示 +- 修改 RayCollapseGrid、RayTable 组件为默认不展示 border + +## 3.1.1 + +### Fixes + +- 修复国际化语言包模块合并处理不能正常合并问题 +- 修复国际化切换时,面包屑、标签页不能正常切换 + +### Feats + +- 新增面包屑 +- 支持国际化语言包分包管理(但是,依旧是合并到一个文件中,所以需要注意 key 的管理) +- 新增国内预览地址 diff --git a/COMMONPROBLEM.md b/COMMONPROBLEM.md new file mode 100644 index 00000000..fa14ec84 --- /dev/null +++ b/COMMONPROBLEM.md @@ -0,0 +1,21 @@ +## 常见问题 + +### 路由 + +#### 缓存失效 + +> 如果出现缓存配置不生效的情况可以按照如下方法进行排查 + +- 查看 APP_KEEP_ALIVE setupKeepAlive 属性是否配置为 true +- 查看每个组件的 `name` 是否唯一,[`KeepAlive`](https://cn.vuejs.org/guide/built-ins/keep-alive.html) 组件重度依赖组件 `name` 作为唯一标识。详情可以查看官方文档 +- 查看该页面的路由配置是否正确,比如:`path` 是否按照模板约定方式进行配置 + +#### 自动导入失败 + +> 模板采用自动导入路由模块方式。如果发现路由导入有误、或者导入报错,请查看文件命名是否有误。 + +### 国际化 + +#### 国际化切换错误、警告 + +> 模板二次封装 [`useI18n`](https://github.com/XiaoDaiGua-Ray/ray-template/blob/main/src/locales/useI18n.ts) 方法,首选该方法作为国际化语言切换方法。 diff --git a/MANUAL.md b/MANUAL.md new file mode 100644 index 00000000..2a1da888 --- /dev/null +++ b/MANUAL.md @@ -0,0 +1,161 @@ +## Ray Template 使用手册 + +## 前言 + +> `Ray Template` 默认使用 `yarn` 作为包管理器,并且默认启用严格模式的 `eslint`。在导入模块的时候除 `.ts` `.tsx` `.d.ts` 文件等不需要手动补全后缀名,其余的模块导入应该手动补全所有后缀名。 + +### 使用 + +#### 依赖安装 + +```sh +# yarn +yarn + +# npm +npm i +``` + +#### 启动项目 + +```sh +# yarn +yarn dev + +# npm +npm run dev +``` + +#### 构建项目 + +```sh +# yarn +yarn build + +# npm +npm run build +``` + +### 国际化 + +#### Tip + +- 每个新的语言包文件的文件名视为 path 开头(menu.json => menu.xxx) + +#### 新增语言包、新增语言模块 + +> 项目国际化管理模块,统一放置于 `src/locales` 下。 + +##### 文件包 + +- lang +- helper.ts +- index.ts +- useI18n.ts + +> 项目中使用 t locale 方法时,使用模板提供方法(useI18n.ts) + +```tsx +// 引入包 +import { useI18n } from '@/locales/useI18n' + +const { t, locale } = useI18n() + +/** + * + * t: 用于绑定国际化 path + * locale: 用于切换系统语言。参数 key 必须与 lang 包中的语言包一致 + */ + +const demo = () => {t('demo.demo')} + +locale('zh-CN') +locale('en-US') +``` + +##### 新增语言包 + +> 我们举例新增台湾地区语言包。 + +- `src/locales/lang` 文件下创建对应新语言包文件夹与语言文件 + - zh-TW 文件夹 + - zh-TW.ts 文件 +- 创建与原有语言格式一样的文件夹(可以直接 cv 过去) +- 配置语言下拉项(LOCAL_OPTIONS) +- 配置 dayjs 国际化映射(DAYJS_LOCAL_MAP) + +> 具体注意事项看注释。 + +##### 最后 + +> 按照上述步骤操作后,就已经给模板添加了一个新的语言包了。可以在页面的右上角国际化下拉框中看到新增的下拉选项,点击切换后,即可切换到对应的语言了。 + +### 路由 + +#### Tip + +- 在该模板中,路由 path 属性视 `/` 开头的路由为根路由 +- 路由会在初始化的时候将所有路由进行提升(全部提升为顶级路由),所以注意第一条 Tip +- 路由模块会影响菜单的输出显示(菜单模块与路由模块进行拆分开来的) +- 具体路由支持配置属性看 router/README.md 文件 + +#### 文件包 + +- constant 文件放置一些公共东西 +- helper 文件放置 router 的一些 hook 方法 +- modules 页面路由入口(modules 文件中每一个 xxx.ts 文件都会被视为是一个路由模块) +- utils router 拓展方法 +- ... + +##### 新增路由页面 + +- modules 中添加一个新的模块(log.ts) +- 配置路由的相关信息 +- views 中创建 log 相关的页面信息 + +```ts +// 辅助函数,配合 i18n-ally 插件使用 +import { t } from '@/locales/useI18n' + +// 路由配置类型提示 +import type { AppRouteRecordRaw } from '@/router/type' + +const log: AppRouteRecordRaw = { + path: '/log', + name: 'Log', + component: () => import('@/views/log/index.vue'), + meta: { + i18nKey: t('menu.Log'), + icon: 'log', + order: 3, + }, + children: [ + { + path: 'my-log', + name: 'MyLog', + component: () => import('@/views/my-log/index.vue'), + meta: { + i18nKey: t('menu.MyLog'), + order: 0, + }, + }, + { + path: 'group-log', + name: 'MyLog', + component: () => import('@/views/group-log/index.vue'), + meta: { + i18nKey: t('menu.GroupLog'), + order: 0, + }, + }, + ], +} + +export default log +``` + +##### 最后 + +> 打开浏览器可以看到页面菜单上已经有一个日志菜单。 + +#### 未完待续。。。后续慢慢更新该手册 diff --git a/README.md b/README.md new file mode 100644 index 00000000..7392c68d --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# `Ray Template` + + + +[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) + + + +## 感谢 + +> 感谢 对于本人的支持。 + +## 预览地址 + +- [点击预览](https://xiaodaigua-ray.github.io/ray-template/#/) +- [点击预览(加速地址)](https://ray-template.yunkuangao.com/#/) + +## 文档地址 + +- [文档](https://xiaodaigua-ray.github.io/ray-template-doc/) +- [文档(加速地址)](https://ray-template.yunkuangao.com/ray-template-doc/) + +## 更新日志 + +- [日志](https://github.com/XiaoDaiGua-Ray/xiaodaigua-ray.github.io/blob/main/CHANGELOG.md) + +## 常见问题 + +- [常见问题](https://github.com/XiaoDaiGua-Ray/ray-template/blob/main/COMMONPROBLEM.md) + +## 功能 + +- 主题切换 +- 任意深度页面缓存 +- 系统配置化 +- 锁屏 +- 自动化路由 +- 带有拓展功能的表格 +- 封装 `axios` 自动取消重复请求,暴露拦截器注册器 +- 全局菜单搜索 +- 动态菜单(多级菜单) +- 主题色切换 +- 错误页 +- 面包屑 +- 标签页 +- 国际化(允许按模块管理语言包) +- 权限路由 +- 动态切换主题、贴花的 `EChart` 图 +- 最佳构建体验 +- 体积分析 +- 还有一些不值一提的小东西... + +## 未来 + +> 根据个人时间空余情况,会不定时对该模板进行更新和迭代。希望将该工具的功能不断补全(虽然现在已经是足够日常开发和使用),将该模板打造为一个更加健全的中后台模板。如果你有好的想法和建议,可以直接联系我或者直接提 `issues` 即可。 + +## 前言 + +> 该项目模板采用 `vue3.x` `vite4.x` `pinia` `tsx` 进行开发。 +> 使用 `naive ui` 作为组件库。 +> 预设了最佳构建体验的配置与常用搬砖工具。意在提供一个简洁、快速上手的模板。 +> 该模板不支持移动端设备。 + +## 提示 + +> 项目默认启用严格模式 `eslint`,但是由于 `vite-plugin-eslint` 插件优先级最高,所以如果出现自动导入类型错误提示,请优先解决其他问题。 +> 建议开启 `vscode` 保存自动修复功能。 + +## 版本说明 + +> 做了一些大的改动升级,让模板更加好用了一点,默认主题色也做了变更更好看了一点。啰嗦两句,好像也没啥其他的了... + +## 拉取依赖 + +```sh +# yarn + +yarn +``` + +```sh +# npm + +npm install +``` + +## 启动项目 + +```sh +# yarn + +yarn dev +``` + +```sh +# npm + +npm run dev +``` + +## 项目打包 + +```sh +# yarn + +yarn build +``` + +```sh +# npm + +npm run build +``` + +## 预览项目 + +```sh +# yarn + +yarn preview +``` + +```sh +# npm + +npm run preview +``` + +## 体积分析 + +```sh +# yarn + +yarn report +``` + +```sh +# npm + +npm run report +``` + +## 项目依赖 + +- [pinia](https://pinia.vuejs.org/) `全局状态管理器` +- [@vueuse](https://vueuse.org/) `vue3 hooks` +- [vue-router](https://router.vuejs.org/zh/) `router` +- [axios](http://axios-js.com/zh-cn/docs/index.html) `ajax request` +- [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html) `国际化` +- [scrollreveal.js](https://scrollrevealjs.org/) `滚动加载动画`(暂时移除) +- [crypto-js](https://github.com/brix/crypto-js) `加密` +- [vite-svg-loader](https://github.com/jpkleemans/vite-svg-loader) `svg组件化` +- [vite-plugin-svg-icons](https://github.com/vbenjs/vite-plugin-svg-icons/blob/main/README.zh_CN.md) `svg雪碧图` +- [echarts5](https://echarts.apache.org/examples/zh/index.html#chart-type-line) `可视化` +- [lodash-es](https://www.lodashjs.com/) `拓展方法` +- 还有一些后续补充的,懒得写了。。。自己看项目依赖页面 + +## 基础组件 + +- `RayIcon` `svg icon` +- `RayChart` 基于 `echarts5.x` 封装可视化组件 +- `RayTransitionComponent` 带过渡动画路由组件,效果与 `RouterView` 相同 +- `RayTable` 基于 `Naive UI DataTable` 组件封装,实现了一些小功能 +- `RayCollapseGrid` 基于 `Naive UI NGrid` 组件封装的可折叠操作栏 + +## 项目结构 + +``` +- locales: 国际化多语言入口(本项目采用 json 格式) + +- assets: 项目静态资源入口 + +- component: 全局共用组件 + +- icons: 项目svg图标资源,需要配合 RayIcon 组件使用 + +- language: 国际化 + +- layout: 全局页面结构入口 + +- router: 路由表 + +- store: 全局状态管理入口 + +- styles: 全局公共样式入口 + +- types: 全局 type + +- utils: 工具包 + +- views: 页面入口 + +- vite-plugin: 插件注册 +``` + +## 浏览器支持 + +> 仅支持现代浏览器,不支持 `IE` + +## 最后,希望大家搬砖愉快 + +## 贡献者 + + + + + + + + + + +
Cloud
Cloud

🔧
+ + + + + + +## Contributors ✨ + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! diff --git a/cfg.ts b/cfg.ts new file mode 100644 index 00000000..01d41f59 --- /dev/null +++ b/cfg.ts @@ -0,0 +1,150 @@ +/** + * + * @author Ray + * + * @date 2023-04-06 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 系统配置文件入口 + * + * 配置范围: + * - 构建: 开发构建、打包构建、预览构建、体积分析构建等 + * - 系统: 根路由、标题、浏览器标题、别名等 + * - 请求: 代理配置 + * + * 如果需要新增相关内容, 需要在 src/types/modules/cfg.ts 中进行类型配置 + * ``` + * interface Config // config 内容类型配置 + * + * interface AppConfig // __APP_CFG__ 内容配置 + * ``` + * + * __APP_CFG__ 说明 + * ``` + * 该属性是用于全局注入的配置方法 + * + * const { appPrimaryColor } = __APP_CFG__ + * + * 以上例子展示, 从 __APP_CFG__ 中解构取出 appPrimaryColor 根路由配置信息 + * __APP_CFG__ 会被挂载于全局变量 `window` 下(vite define 默认是挂载于 window 下) + * ``` + */ + +import path from 'node:path' + +import { + HTMLTitlePlugin, + buildOptions, + mixinCSSPlugin, +} from './vite-plugin/index' +import { APP_THEME } from './src/appConfig/designConfig' +import { + PRE_LOADING_CONFIG, + ROOT_ROUTE, + SIDE_BAR_LOGO, +} from './src/appConfig/appConfig' + +import type { AppConfigExport } from '@/types/modules/cfg' + +const config: AppConfigExport = { + /** 公共基础路径配置, 如果为空则会默认以 '/' 填充 */ + base: '/ray-template/', + /** 配置首屏加载信息 */ + preloadingConfig: PRE_LOADING_CONFIG, + /** 默认主题色(不可省略, 必填), 也用于 ejs 注入 */ + appPrimaryColor: APP_THEME.APP_PRIMARY_COLOR, + sideBarLogo: SIDE_BAR_LOGO, + /** + * + * 预处理全局需要注入的 css 文件 + * + * 预设: + * - ./src/styles/mixins.scss + * - ./src/styles/setting.scss + * - ./src/styles/theme.scss + * + * 如果需要删除或者修改, 需要同步修改目录下的 css 文件 + */ + mixinCSS: mixinCSSPlugin([ + './src/styles/mixins.scss', + './src/styles/setting.scss', + ]), + /** + * + * 版权信息 + * + * 也可以当作页底设置, 看实际业务需求 + */ + copyright: 'Copyright © 2022-present Ray', + /** + * + * 浏览器标题 + */ + title: HTMLTitlePlugin('Ray Template'), + /** + * + * 配置 HMR 特定选项(端口、主机、路径和协议) + */ + server: { + host: '0.0.0.0', + port: 9527, + open: false, + https: false, + strictPort: false, + fs: { + strict: false, + allow: [], + }, + proxy: { + '/api': { + target: 'url', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + '/office': { + target: 'https://office.yka.one/', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/office/, ''), + }, + }, + }, + /** + * + * 打包相关配置 + */ + buildOptions: buildOptions, + /** + * + * 预设别名 + * - `@`: `src` 根目录 + * - `@use-utils`: `src/utils` 根目录 + * - `@use-api`: `src/axios/api` 根目录 + * - `@use-images`: `src/assets/images` 根目录 + */ + alias: [ + { + find: '@', + replacement: path.resolve(__dirname, './src'), + }, + { + find: '@use-utils', + replacement: path.resolve(__dirname, './src/utils'), + }, + { + find: '@use-api', + replacement: path.resolve(__dirname, './src/axios/api'), + }, + { + find: '@use-images', + replacement: path.resolve(__dirname, './src/assets/images'), + }, + ], +} + +export default config diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 00000000..552880ad --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,21 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'bug', + 'feat', + 'fix', + 'docs', + 'style', + 'refactor', + 'test', + 'chore', + 'revert', + 'merge', + ], + ], + }, +} diff --git a/index.html b/index.html new file mode 100644 index 00000000..b5fa5c4e --- /dev/null +++ b/index.html @@ -0,0 +1,106 @@ + + + + + + + Vite + Vue + TS + + + +
+
+
+
+ <%= preloadingConfig.title %> +
+
+
+
+ + + diff --git a/package.json b/package.json new file mode 100644 index 00000000..8702e37b --- /dev/null +++ b/package.json @@ -0,0 +1,114 @@ +{ + "name": "ray-template", + "private": false, + "version": "4.0.2", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build --mode production", + "preview": "vite preview", + "test": "vue-tsc --noEmit && vite build --mode test", + "dev-build": "vue-tsc --noEmit && vite build --mode development", + "report": "vue-tsc --noEmit && vite build --mode report", + "prepare": "husky install" + }, + "lint-staged": { + "src/**/*.{vue,jsx,ts,tsx,json}": [ + "prettier --write", + "eslint", + "git add" + ] + }, + "dependencies": { + "@vueuse/core": "^9.1.0", + "axios": "^1.2.0", + "clipboard": "^2.0.11", + "crypto-js": "^4.1.1", + "currency.js": "^2.0.4", + "dayjs": "^1.11.7", + "echarts": "^5.4.0", + "lodash-es": "^4.17.21", + "naive-ui": "^2.34.4", + "pinia": "^2.0.17", + "pinia-plugin-persistedstate": "^2.4.0", + "print-js": "^1.6.0", + "qrcode.vue": "^3.3.4", + "sass": "^1.54.3", + "screenfull": "^6.0.2", + "vue": "^3.2.47", + "vue-i18n": "^9.2.2", + "vue-router": "^4.1.3", + "vuedraggable": "^4.1.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@babel/core": "^7.20.2", + "@babel/eslint-parser": "^7.19.1", + "@commitlint/cli": "^17.4.2", + "@commitlint/config-conventional": "^17.4.2", + "@intlify/unplugin-vue-i18n": "^0.5.0", + "@types/crypto-js": "^4.1.1", + "@types/lodash-es": "^4.17.7", + "@types/scrollreveal": "^0.0.8", + "@typescript-eslint/eslint-plugin": "^5.61.0", + "@typescript-eslint/parser": "^5.61.0", + "@vitejs/plugin-vue": "^4.1.0", + "@vitejs/plugin-vue-jsx": "^3.0.1", + "@vue/eslint-config-prettier": "^7.1.0", + "@vue/eslint-config-typescript": "^11.0.3", + "autoprefixer": "^10.4.8", + "depcheck": "^1.4.3", + "eslint": "^8.44.0", + "eslint-config-prettier": "^8.8.0", + "eslint-config-standard-with-typescript": "^23.0.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0", + "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.31.10", + "eslint-plugin-vue": "^9.15.1", + "husky": "^8.0.3", + "lint-staged": "^13.1.0", + "postcss": "^8.1.0", + "postcss-px-to-viewport": "^1.1.1", + "prettier": "^2.7.1", + "rollup-plugin-visualizer": "^5.8.3", + "svg-sprite-loader": "^6.0.11", + "typescript": "^5.0.2", + "unplugin-auto-import": "^0.11.0", + "unplugin-vue-components": "^0.22.0", + "vite": "^4.3.9", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-ejs": "^1.6.4", + "vite-plugin-eslint": "^1.8.1", + "vite-plugin-imp": "^2.3.1", + "vite-plugin-inspect": "^0.7.26", + "vite-plugin-svg-icons": "^2.0.1", + "vite-svg-loader": "^3.4.0", + "vue-tsc": "^1.4.2" + }, + "description": "", + "main": "index.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/XiaoDaiGua-Ray/xiaodaigua-ray.github.io.git" + }, + "keywords": [ + "ray-template", + "vue3.2模板", + "vue3-tsx-vite-pinia", + "ray template", + "Ray Template", + "admin template", + "中后台模板" + ], + "files": [ + "**/*" + ], + "author": "Ray", + "license": "ISC", + "bugs": { + "url": "https://github.com/XiaoDaiGua-Ray/xiaodaigua-ray.github.io/issues" + }, + "homepage": "https://github.com/XiaoDaiGua-Ray/xiaodaigua-ray.github.io#readme" +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 00000000..320ef306 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,31 @@ +module.exports = { + plugins: { + autoprefixer: { + overrideBrowserslist: [ + 'Android 4.1', + 'iOS 7.1', + 'Chrome > 31', + 'ff > 31', + 'ie >= 8', + 'last 10 versions', + ], + grid: true, + }, + // 'postcss-px-to-viewport': { + // /** 视窗的宽度(设计稿的宽度) */ + // viewportWidth: 1920, + // /** 视窗的高度(设计稿高度, 一般无需指定) */ + // viewportHeight: 1080, + // /** 指定 px 转换为视窗单位值的小数位数 */ + // unitPrecision: 3, + // /** 指定需要转换成的视窗单位 */ + // viewportUnit: 'vw', + // /** 指定不转换为视窗单位的类 */ + // selectorBlackList: ['.ignore'], + // /** 小于或等于 1px 不转换为视窗单位 */ + // minPixelValue: 1, + // /** 允许在媒体查询中转换 px */ + // mediaQuery: false, + // }, + }, +} diff --git a/public/ray.svg b/public/ray.svg new file mode 100644 index 00000000..daaef75a --- /dev/null +++ b/public/ray.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..bc602e83 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,105 @@ +import RayGlobalProvider from '@/components/RayGlobalProvider/index' +import { RouterView } from 'vue-router' +import GlobalSpin from '@/spin/index' +import LockScreen from '@/components/AppComponents/AppLockScreen/index' + +import { getStorage } from '@/utils/cache' +import { get } from 'lodash-es' +import { useSetting } from '@/store' +import { addClass, removeClass, addStyle, colorToRgba } from '@/utils/element' + +import type { SettingState } from '@/store/modules/setting/type' + +const App = defineComponent({ + name: 'App', + setup() { + const settingStore = useSetting() + + const { themeValue } = storeToRefs(settingStore) + + /** 同步主题色变量至 body, 如果未获取到缓存值则已默认值填充 */ + const syncPrimaryColorToBody = () => { + const { + appPrimaryColor: { primaryColor, primaryFadeColor }, + } = __APP_CFG__ // 默认主题色 + const body = document.body + + const primaryColorOverride = getStorage( + 'piniaSettingStore', + 'localStorage', + ) + + if (primaryColorOverride) { + const _p = get( + primaryColorOverride, + 'primaryColorOverride.common.primaryColor', + primaryColor, + ) + const _fp = colorToRgba(_p, 0.3) + + /** 设置全局主题色 css 变量 */ + body.style.setProperty('--ray-theme-primary-color', _p) + body.style.setProperty( + '--ray-theme-primary-fade-color', + _fp || primaryFadeColor, + ) + } + } + + /** 隐藏加载动画 */ + const hiddenLoadingAnimation = () => { + /** pre-loading-animation 是默认 id */ + const el = document.getElementById('pre-loading-animation') + + if (el) { + addStyle(el, { + display: 'none', + }) + } + } + + syncPrimaryColorToBody() + hiddenLoadingAnimation() + + /** 切换主题时, 同步更新 body class 以便于进行自定义 css 配置 */ + watch( + () => themeValue.value, + (newData) => { + /** + * + * 初始化时根据当前主题色进行初始化 body 的 class 属性 + * + * 根据 themeValue 进行初始化 + */ + const body = document.body + const darkClassName = 'ray-template--dark' + const lightClassName = 'ray-template--light' + + newData + ? removeClass(body, lightClassName) + : removeClass(body, darkClassName) + + addClass(body, newData ? darkClassName : lightClassName) + }, + { + immediate: true, + }, + ) + }, + render() { + return ( + + + + + {{ + default: () => , + description: () => 'lodaing...', + }} + + + ) + }, +}) + +export default App diff --git a/src/appConfig/appConfig.ts b/src/appConfig/appConfig.ts new file mode 100644 index 00000000..992e827c --- /dev/null +++ b/src/appConfig/appConfig.ts @@ -0,0 +1,106 @@ +/** + * + * @author Ray + * + * @date 2023-05-23 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** 系统配置 */ + +import type { + LayoutSideBarLogo, + PreloadingConfig, + RootRoute, +} from '@/types/modules/cfg' +import type { AppMenuConfig, AppKeepAlive } from '@/types/modules/appConfig' + +/** + * + * 系统缓存 + * + * 说明: + * - setupKeepAlive: 是否启用系统页面缓存, 设置为 false 则关闭系统页面缓存 + * - keepAliveExclude: 排除哪些页面不缓存 + * - maxKeepAliveLength: 最大缓存页面数量 + */ +export const APP_KEEP_ALIVE: Readonly = { + setupKeepAlive: true, + keepAliveExclude: [], + maxKeepAliveLength: 5, +} + +/** 首屏加载信息配置 */ +export const PRE_LOADING_CONFIG: PreloadingConfig = { + title: 'Ray Template', + tagColor: '#ff6700', + titleColor: '#2d8cf0', +} + +/** + * + * 配置根页面 + * 该项目所有重定向至首页, 都依赖该配置项 + * + * 如果修改了该项目的首页路由配置, 需要更改该配置项, 以免重定向首页操作出现错误 + */ +export const ROOT_ROUTE: Readonly = { + name: 'Dashboard', + path: '/dashboard', +} + +/** + * + * icon: LOGO 图标, 依赖 `RayIcon` 实现(如果为空则不会渲染图标) + * title: LOGO 标题 + * url: 点击跳转地址, 如果不配置该属性, 则不会触发跳转 + * jumpType: 跳转类型(station: 项目内跳转, outsideStation: 新页面打开) + * + * 如果不设置该属性或者为空, 则不会渲染 LOGO + */ +export const SIDE_BAR_LOGO: LayoutSideBarLogo = { + icon: 'ray', + title: 'Ray Template', + url: '/dashboard', + jumpType: 'station', +} + +/** + * + * 系统菜单折叠配置 + * + * MENU_COLLAPSED_WIDTH 配置仅当 MENU_COLLAPSED_MODE 为 width 风格时才有效 + * + * MENU_COLLAPSED_MODE: + * - transform: 边栏将只会移动它的位置而不会改变宽度 + * - width: Sider 的内容宽度将会被实际改变 + * MENU_COLLAPSED_ICON_SIZE 配置菜单未折叠时图标的大小 + * MENU_COLLAPSED_INDENT 配置菜单每级的缩进 + * MENU_ACCORDION 手风琴模式 + */ +export const APP_MENU_CONFIG: Readonly = { + MENU_COLLAPSED_WIDTH: 64, + MENU_COLLAPSED_MODE: 'width', + MENU_COLLAPSED_ICON_SIZE: 22, + MENU_COLLAPSED_INDENT: 24, + MENU_ACCORDION: false, +} + +/** + * + * 系统默认缓存 key 配置 + * 仅暴露部分系统获取缓存配置, 其余 key 暂不开放 + * + * 说明: + * - signin: 登陆信息缓存 key + * - localeLanguage: 国际化默认缓存 key + * - token: token key + */ +export const APP_CATCH_KEY = { + signin: 'signin', + localeLanguage: 'localeLanguage', + token: 'token', +} as const diff --git a/src/appConfig/designConfig.ts b/src/appConfig/designConfig.ts new file mode 100644 index 00000000..013d2d67 --- /dev/null +++ b/src/appConfig/designConfig.ts @@ -0,0 +1,63 @@ +/** + * + * @author Ray + * + * @date 2023-05-19 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** 系统颜色风格配置入口 */ + +import type { AppTheme } from '@/types/modules/cfg' + +export const APP_THEME: AppTheme = { + /** + * + * 系统主题颜色预设色盘 + * 支持 RGBA、RGB、十六进制 + */ + APP_THEME_COLOR: [ + '#2d8cf0', + '#0960bd', + '#536dfe', + '#ff5c93', + '#ee4f12', + '#9c27b0', + '#ff9800', + '#18A058', + ], + /** 系统主题色 */ + APP_PRIMARY_COLOR: { + /** 主题色 */ + primaryColor: '#2d8cf0', + /** 主题辅助色(用于整体 hover、active 等之类颜色) */ + primaryFadeColor: 'rgba(45, 140, 240, 0.3)', + }, + /** + * + * 配置系统 naive-ui 主题色 + * 官网文档地址: + * + * 注意: + * - APP_PRIMARY_COLOR common 配置优先级大于该配置 + * + * 如果需要定制化整体组件样式, 配置示例 + * ``` + * const themeOverrides: GlobalThemeOverrides = { + * common: { + * primaryColor: '#FF0000', + * }, + * Button: { + * textColor: '#FF0000', + * }, + * } + * ``` + * + * 具体自行查看官网, 还有模式更佳丰富的 peers 主题变量配置 + * 地址: + */ + APP_NAIVE_UI_THEME_OVERRIDES: {}, +} diff --git a/src/appConfig/localConfig.ts b/src/appConfig/localConfig.ts new file mode 100644 index 00000000..81ee6af3 --- /dev/null +++ b/src/appConfig/localConfig.ts @@ -0,0 +1,62 @@ +/** + * + * @author Ray + * + * @date 2023-05-19 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** 国际化相关配置 */ + +import type { DayjsLocal, DayjsLocalMap } from '@/dayjs/type' +import type { AppLocalesDropdownMixedOption } from '@/locales/type' + +/** + * + * 语言包语种添加后, 需要在此文件配置语言包 + * 该配置中的 key 也会影响 naiveLocales 方法, 配置后请仔细核对一下 + * + * 添加新的语言包后, 如果需要其类型提示, 需要在 CurrentAppMessages 中添加新的类型 + */ +export const LOCAL_OPTIONS: AppLocalesDropdownMixedOption[] = [ + { + key: 'zh-CN', + label: '中文(简体)', + }, + { + key: 'en-US', + label: 'English(US)', + }, +] + +/** + * + * 系统默认语言 + * + * 配置时应该与 LOCAL_OPTIONS 的 key 一致 + */ +export const SYSTEM_DEFAULT_LOCAL = 'zh-CN' + +/** + * + * dayjs 默认语言格式 + * 默认为英文(en) + * + * 系统默认设置为中文(大陆-简体) + */ +export const DEFAULT_DAYJS_LOCAL: DayjsLocal = 'zh-cn' + +/** + * + * i18n 国际化配置与 dayjs 配置的映射入口 + * + * key 应该与 LOCAL_OPTIONS key 一致 + * 配置时请仔细检查 + */ +export const DAYJS_LOCAL_MAP: DayjsLocalMap = { + 'zh-CN': 'zh-cn', + 'en-US': 'en', +} diff --git a/src/appConfig/regexConfig.ts b/src/appConfig/regexConfig.ts new file mode 100644 index 00000000..1c0c5b06 --- /dev/null +++ b/src/appConfig/regexConfig.ts @@ -0,0 +1,22 @@ +/** + * + * @author Ray + * + * @date 2023-06-12 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 正则入口 + * 系统公共正则, 配置在该文件中 + */ + +export const APP_REGEX: Record = { + /** css 尺寸单位匹配 */ + validerCSSUnit: + /^\d+(\.\d+)?(px|em|rem|%|vw|vh|vmin|vmax|cm|mm|in|pt|pc|ch|ex|q|s|ms|deg|rad|turn|grad|hz|khz|dpi|dpcm|dppx|fr|auto)$/, +} diff --git a/src/appConfig/requestConfig.ts b/src/appConfig/requestConfig.ts new file mode 100644 index 00000000..608663f8 --- /dev/null +++ b/src/appConfig/requestConfig.ts @@ -0,0 +1,22 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import type { AxiosConfig } from '@/types/modules/appConfig' + +/** axios 相关配置 */ +export const AXIOS_CONFIG: AxiosConfig = { + baseURL: '', // `import.meta.env`, + withCredentials: false, // 是否允许跨域携带 `cookie` + timeout: 5 * 1000, + headers: { + 'Content-Type': 'application/json', + }, +} diff --git a/src/appConfig/routerConfig.ts b/src/appConfig/routerConfig.ts new file mode 100644 index 00000000..49b62053 --- /dev/null +++ b/src/appConfig/routerConfig.ts @@ -0,0 +1,55 @@ +/** + * + * @author Ray + * + * @date 2023-05-19 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** vue-router 相关配置入口 */ + +import type { LayoutInst } from 'naive-ui' + +/** + * + * 内容区域 ref 注册 + * 可以控制内容区域当前滚动位置 + * 如果你需要在切换路由时候配置自定义滚动到某个视图区域时, 可以使用该属性提供的方法(scrollTo) + * + * 请注意 + * 如果你动态的添加了某个属性后, 希望控制滚动条滚动到某个区域时, 应该注意 dom 挂载后再执行该方法 + * @example + * ```ts + * nextTick().then(() => { + * LAYOUT_CONTENT_REF.value?.scrollTo() + * }) + * ``` + */ +export const LAYOUT_CONTENT_REF = ref() + +/** 是否启用路由切换时顶部加载条 */ +export const SETUP_ROUTER_LOADING_BAR = true + +/** 是否启用路由守卫, 如果设置为 false 则不会触发路由切换校验 */ +export const SETUP_ROUTER_GUARD = true + +/** + * + * 路由白名单(不进行权限校验路由) + * + * 路由表单白名单 + * + * 如果需要启用该功能, 则需要配置路由 name 属性, 并且需要一一对应(对大小写敏感) + * 并且在配置 route name 属性时, 如果 name 类型为 symbol 的话, 会认为该路由永远不与白名单列表进行匹配 + */ +export const WHITE_ROUTES: string[] = ['RLogin', 'ErrorPage', 'RayTemplateDoc'] + +/** + * + * 超级管理员 + * 配置默认超级管理员, 默认拥有全部最高权限 + */ +export const SUPER_ADMIN: (string | number)[] = ['admin'] diff --git a/src/assets/images/ray.svg b/src/assets/images/ray.svg new file mode 100644 index 00000000..daaef75a --- /dev/null +++ b/src/assets/images/ray.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/axios/README.md b/src/axios/README.md new file mode 100644 index 00000000..9b5c590c --- /dev/null +++ b/src/axios/README.md @@ -0,0 +1,71 @@ +## 说明 + +> axios 包,全局的 axios 使用入口 +> `src/axios/instance.ts` 文件为 axios 实例文件,应该不可更改,如果需要有拦截器相关的操作,应该在 `src/axios/inject` 文件下按照需求在对应注册器中进行注册相关方法。该项目将 axios instance 与 axios interceptor 进行解耦,避免实例文件的臃肿。 + +## 工具函数 + +- BeforeFetchFunction +- FetchErrorFunction + +> 两个工具函数方便类型推导。 + +## 约束 + +### 拦截器添加 + +> 这里以请求拦截器添加为示例 + +```ts +/** + * + * 拦截器说明 + * + * 注册一个根据系统当前环境是否携带测试环境请求拦截器 + */ + +import { appendRequestHeaders } from '@/axios/helper/axiosCopilot' + +import type { + RequestInterceptorConfig, + BeforeFetchFunction, + FetchErrorFunction, +} from '@/axios/type' + +const injectRequestHeaderOfEnv: BeforeFetchFunction< + RequestInterceptorConfig +> = (ins, mode) => { + if (mode === 'development') { + appendRequestHeaders(ins, [ + { + key: 'Development-Mode', + value: 'development', + }, + ]) + } +} + +/** + * + * 在 setupRequestInterceptor 中注册 + */ + +export const setupRequestInterceptor = () => { + setImplement( + 'implementRequestInterceptorArray', + [injectRequestHeaderOfEnv], + 'ok', + ) +} + +/** 至此完成了请求拦截器的注册 */ +``` + +### 注册器 + +> 每个类型注册器都有两个方法,用于注册拦截器方法。都以 setupXXX 开头命名。注册器以队列形式管理拦截器方法,所以可能需要注意执行顺序。如果有新的拦截器方法,需要根据其使用场景在对应的注册器中注册(注册器第二个参数中注册)。 + +- 请求注册器: setupRequestInterceptor +- 请求错误注册器: setupRequestErrorInterceptor +- 响应注册器: setupResponseInterceptor +- 响应错误注册器: setupResponseErrorInterceptor diff --git a/src/axios/api/test.ts b/src/axios/api/test.ts new file mode 100644 index 00000000..125ce98e --- /dev/null +++ b/src/axios/api/test.ts @@ -0,0 +1,44 @@ +/** + * + * @author Ray + * + * @date 2023-03-31 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 该方法演示如何使用 axios + * + * 示范如何完整批注响应体及其数据: + * + * ``` + * const demoRequest = () => { + * return {} as AxiosResponseBody + * } + * ``` + */ + +import useRequest from '@/axios/instance' + +import type { AxiosResponseBody } from '@/types/modules/axios' + +interface AxiosTestResponse extends UnknownObjectKey { + data: UnknownObjectKey[] + city?: string +} + +/** + * + * @returns 测试 + * + * @medthod get + */ +export const onAxiosTest = async (city: string) => { + return useRequest({ + url: `https://www.tianqiapi.com/api?version=v9&appid=23035354&appsecret=8YvlPNrz&city=${city}`, + }) +} diff --git a/src/axios/helper/axiosCopilot.ts b/src/axios/helper/axiosCopilot.ts new file mode 100644 index 00000000..60150dc5 --- /dev/null +++ b/src/axios/helper/axiosCopilot.ts @@ -0,0 +1,35 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** axios 拦截器工具 */ + +import type { RawAxiosRequestHeaders, AxiosRequestConfig } from 'axios' +import type { RequestHeaderOptions } from '../type' + +/** + * + * @param instance axios instance + * @param options axios headers options + * + * @remark 自定义 `axios` 请求头配置 + */ +export const appendRequestHeaders = ( + instance: AxiosRequestConfig, + options: RequestHeaderOptions[], +) => { + if (instance) { + const requestHeaders = instance.headers as RawAxiosRequestHeaders + + options.forEach((curr) => { + requestHeaders[curr.key] = curr.value + }) + } +} diff --git a/src/axios/helper/canceler.ts b/src/axios/helper/canceler.ts new file mode 100644 index 00000000..02e32c90 --- /dev/null +++ b/src/axios/helper/canceler.ts @@ -0,0 +1,82 @@ +/** + * + * @author Ray + * + * @date 2023-02-27 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 自动取消重复请求 + * + * 可以根据自己项目进行定制化配置 + */ + +import type { AxiosRequestConfig } from 'axios' + +export default class RequestCanceler { + pendingRequest: Map + + constructor() { + this.pendingRequest = new Map() + } + + /** + * + * @param config 请求体 config + * @returns 返回当前请求拼接 key + * + * @remark 将当前请求 config 生成 request key + */ + generateRequestKey(config: AxiosRequestConfig): string { + const { method, url } = config + + return [ + url || '', + method || '', + JSON.stringify(config.params), + JSON.stringify(config.data), + ].join('&') + } + + /** + * + * @param config axios request config + * + * @remark 给请求体添加 signal 属性, 用于取消请求 + */ + addPendingRequest(config: AxiosRequestConfig) { + const requestKey = this.generateRequestKey(config) + + if (!this.pendingRequest.has(requestKey)) { + const controller = new AbortController() + + config.signal = controller.signal + + this.pendingRequest.set(requestKey, controller) + } else { + // 如果已经有该 key 则重新挂载 signal + config.signal = this.pendingRequest.get(requestKey)?.signal + } + } + + /** + * + * @param config axios request config + * + * @remark 取消该请求, 并且清除 map 中对应 generateRequestKey value + */ + removePendingRequest(config: AxiosRequestConfig) { + const requestKey = this.generateRequestKey(config) + + if (this.pendingRequest.has(requestKey)) { + this.pendingRequest.get(requestKey)!.abort() + + this.pendingRequest.delete(requestKey) + } + } +} diff --git a/src/axios/helper/interceptor.ts b/src/axios/helper/interceptor.ts new file mode 100644 index 00000000..7a6c2e54 --- /dev/null +++ b/src/axios/helper/interceptor.ts @@ -0,0 +1,145 @@ +/** + * + * @author Ray + * + * @date 2023-06-05 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * axios 拦截器注入 + * + * 请求拦截器、响应拦截器 + * 暴露启动方法调用所有已注册方法 + * + * 该拦截器仅适合放置公共的 axios 拦截器操作, 并且采用队列形式管理请求拦截器的注入 + * 所以在使用的时候, 需要按照约定格式进行参数传递 + */ + +import RequestCanceler from '@/axios/helper/canceler' +import { getAppEnvironment } from '@use-utils/hook' + +import type { + RequestInterceptorConfig, + ResponseInterceptorConfig, + ImplementQueue, + ErrorImplementQueue, + FetchType, + AxiosFetchInstance, + AxiosFetchError, +} from '@/axios/type' +import type { AnyFunc } from '@/types/modules/utils' + +/** 当前请求的实例 */ +const axiosFetchInstance: AxiosFetchInstance = { + requestInstance: null, + responseInstance: null, +} +/** 请求失败返回值 */ +const axiosFetchError: AxiosFetchError = { + requestError: null, + responseError: null, +} +/** 请求队列(区分 reslove 与 reject 状态) */ +const implement: ImplementQueue = { + implementRequestInterceptorArray: [], + implementResponseInterceptorArray: [], +} +const errorImplement: ErrorImplementQueue = { + implementRequestInterceptorErrorArray: [], + implementResponseInterceptorErrorArray: [], +} +/** 取消器实例 */ +export const axiosCanceler = new RequestCanceler() + +export const useAxiosInterceptor = () => { + /** 创建拦截器实例 */ + const createAxiosInstance = ( + instance: RequestInterceptorConfig | ResponseInterceptorConfig, + instanceKey: keyof AxiosFetchInstance, + ) => { + instanceKey === 'requestInstance' + ? (axiosFetchInstance['requestInstance'] = + instance as RequestInterceptorConfig) + : (axiosFetchInstance['responseInstance'] = + instance as ResponseInterceptorConfig) + } + + /** 获取当前实例 */ + const getAxiosInstance = (instanceKey: keyof AxiosFetchInstance) => { + return axiosFetchInstance[instanceKey] + } + + /** 设置注入方法队列 */ + const setImplement = ( + key: keyof ImplementQueue | keyof ErrorImplementQueue, + func: AnyFunc[], + fetchType: FetchType, + ) => { + fetchType === 'ok' ? (implement[key] = func) : (errorImplement[key] = func) + } + + /** 获取队列中所有的所有拦截器方法 */ + const getImplement = ( + key: keyof ImplementQueue | keyof ErrorImplementQueue, + fetchType: FetchType, + ): AnyFunc[] => { + return fetchType === 'ok' ? implement[key] : errorImplement[key] + } + + /** 队列执行器 */ + const implementer = (funcs: AnyFunc[], ...args: any[]) => { + if (Array.isArray(funcs)) { + funcs?.forEach((curr) => { + if (typeof curr === 'function') { + curr(...args) + } + }) + } + } + + /** 请求、响应前执行拦截器队列中的所有方法 */ + const beforeFetch = ( + key: keyof AxiosFetchInstance, + implementKey: keyof ImplementQueue | keyof ErrorImplementQueue, + fetchType: FetchType, + ) => { + const funcArr = + fetchType === 'ok' + ? implement[implementKey] + : errorImplement[implementKey] + const instance = getAxiosInstance(key) + const { MODE } = getAppEnvironment() + + if (instance) { + implementer(funcArr, instance, MODE) + } + } + + /** 请求、响应错误时执行队列中所有方法 */ + const fetchError = ( + key: keyof AxiosFetchError, + error: unknown, + errorImplementKey: keyof ErrorImplementQueue, + ) => { + axiosFetchError[key] = error + + const funcArr = errorImplement[errorImplementKey] + const { MODE } = getAppEnvironment() + + implementer(funcArr, error, MODE) + } + + return { + createAxiosInstance, + setImplement, + getImplement, + getAxiosInstance, + beforeFetch, + fetchError, + } +} diff --git a/src/axios/inject/request/provide.ts b/src/axios/inject/request/provide.ts new file mode 100644 index 00000000..f2becd29 --- /dev/null +++ b/src/axios/inject/request/provide.ts @@ -0,0 +1,103 @@ +/** + * + * @author Ray + * + * @date 2023-06-06 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 请求拦截器入口 + * 被注册方法执行时其实例能够保证获取到, 所以不需要做额外空判断 + * 在内部执行方法中, 已经做了边界处理 + * + * 提供两个工具方法, 方便类型推导 + */ + +import { useAxiosInterceptor, axiosCanceler } from '@/axios/helper/interceptor' +import { appendRequestHeaders } from '@/axios/helper/axiosCopilot' +import { APP_CATCH_KEY } from '@/appConfig/appConfig' +import { getStorage } from '@/utils/cache' + +import type { + RequestInterceptorConfig, + BeforeFetchFunction, + FetchErrorFunction, +} from '@/axios/type' + +const { setImplement } = useAxiosInterceptor() + +/** + * + * 这里只是示例如何获取到系统缓存的 token 并且返回请求头 token 的 key 和 value + * 尽可能的拆分每个拦截器的功能函数 + * 这是这个包存在的意义 + * + * 当然你也可以根据 request instance 来特殊处理, 这里暂时不做演示 + */ +const requestHeaderToken = (ins: RequestInterceptorConfig, mode: string) => { + const token = getStorage(APP_CATCH_KEY.token) + + if (ins.url) { + // TODO: 根据 url 不同是否设置 token + } + + return { + key: 'X-TOKEN', + value: token, + } +} + +/** 注入请求头信息 */ +const injectRequestHeaders: BeforeFetchFunction = ( + ins, + mode, +) => { + appendRequestHeaders(ins, [ + requestHeaderToken(ins, mode), + { + key: 'Demo-Header-Key', + value: 'Demo Header Value', + }, + ]) +} + +/** 注入重复请求拦截器 */ +const injectCanceler: BeforeFetchFunction = ( + ins, + mode, +) => { + axiosCanceler.removePendingRequest(ins) // 检查是否存在重复请求, 若存在则取消已发的请求 + axiosCanceler.addPendingRequest(ins) // 把当前的请求信息添加到 pendingRequest 表中 +} + +/** 请求发生错误示例 */ +const requestError: FetchErrorFunction = (error, mode) => { + console.log(error, mode) +} + +/** + * + * 注册请求拦截器 + * 请注意执行顺序 + */ +export const setupRequestInterceptor = () => { + setImplement( + 'implementRequestInterceptorArray', + [injectRequestHeaders, injectCanceler], + 'ok', + ) +} + +/** + * + * 注册请求错误拦截器 + * 请注意执行顺序 + */ +export const setupRequestErrorInterceptor = () => { + setImplement('implementRequestInterceptorErrorArray', [requestError], 'error') +} diff --git a/src/axios/inject/response/provide.ts b/src/axios/inject/response/provide.ts new file mode 100644 index 00000000..34eba431 --- /dev/null +++ b/src/axios/inject/response/provide.ts @@ -0,0 +1,77 @@ +/** + * + * @author Ray + * + * @date 2023-06-06 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 响应拦截器入口 + * 被注册方法执行时其实例能够保证获取到, 所以不需要做额外空判断 + * 在内部执行方法中, 已经做了边界处理 + * + * 提供两个工具方法, 方便类型推导 + */ + +import { useAxiosInterceptor, axiosCanceler } from '@/axios/helper/interceptor' + +import type { + ResponseInterceptorConfig, + BeforeFetchFunction, + FetchErrorFunction, +} from '@/axios/type' + +const { setImplement } = useAxiosInterceptor() + +/** 响应成功后移除缓存请求 url */ +const injectResponseCanceler: BeforeFetchFunction = ( + ins, + mode, +) => { + axiosCanceler.removePendingRequest(ins.config) +} + +/** + * + * @param error 错误信息 + * @param mode 当前环境 + * + * 你可以在响应错误的时候做一些什么 + * 这里不做具体演示 + * + * 方法执行时会有两个参数, 可以根据报错信息与环境定做一些处理 + */ +const responseError: FetchErrorFunction = (error, mode) => { + console.log(error, mode) +} + +/** + * + * 注册响应拦截器 + * 请注意执行顺序 + */ +export const setupResponseInterceptor = () => { + setImplement( + 'implementResponseInterceptorArray', + [injectResponseCanceler], + 'ok', + ) +} + +/** + * + * 注册响应错误拦截器 + * 请注意执行顺序 + */ +export const setupResponseErrorInterceptor = () => { + setImplement( + 'implementResponseInterceptorErrorArray', + [responseError], + 'error', + ) +} diff --git a/src/axios/instance.ts b/src/axios/instance.ts new file mode 100644 index 00000000..5f717cf1 --- /dev/null +++ b/src/axios/instance.ts @@ -0,0 +1,80 @@ +/** + * + * @author Ray + * + * @date 2022-11-18 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 请求拦截器与响应拦截器 + * 如果有需要拓展拦截器, 请在 inject 目录下参照示例方法继续拓展 + * 该页面不做改动与配置 + */ + +import axios from 'axios' +import { AXIOS_CONFIG } from '@/appConfig/requestConfig' +import { useAxiosInterceptor, axiosCanceler } from '@/axios/helper/interceptor' +import { + setupResponseInterceptor, + setupResponseErrorInterceptor, +} from '@/axios/inject/response/provide' +import { + setupRequestInterceptor, + setupRequestErrorInterceptor, +} from '@/axios/inject/request/provide' + +import type { AxiosInstanceExpand } from './type' + +const server: AxiosInstanceExpand = axios.create(AXIOS_CONFIG) +const { createAxiosInstance, beforeFetch, fetchError } = useAxiosInterceptor() + +// 请求拦截器 +server.interceptors.request.use( + (request) => { + // 生成 request instance + createAxiosInstance(request, 'requestInstance') + // 初始化拦截器所有已注入方法 + setupRequestInterceptor() + // 执行拦截器所有已注入方法 + beforeFetch('requestInstance', 'implementRequestInterceptorArray', 'ok') + + return request + }, + (error) => { + // 初始化拦截器所有已注入方法(错误状态) + setupRequestErrorInterceptor() + // 执行所有已注入方法 + fetchError('requestError', error, 'implementRequestInterceptorErrorArray') + + return Promise.reject(error) + }, +) + +// 响应拦截器 +server.interceptors.response.use( + (response) => { + createAxiosInstance(response, 'responseInstance') + setupResponseInterceptor() + beforeFetch('responseInstance', 'implementResponseInterceptorArray', 'ok') + + const { data } = response + + return Promise.resolve(data) + }, + (error) => { + setupResponseErrorInterceptor() + fetchError('responseError', error, 'implementResponseInterceptorErrorArray') + + // 注销该失败请求的取消器 + axiosCanceler.removePendingRequest(error.config || {}) + + return Promise.reject(error) + }, +) + +export default server diff --git a/src/axios/type.ts b/src/axios/type.ts new file mode 100644 index 00000000..c05d1184 --- /dev/null +++ b/src/axios/type.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + AxiosHeaders, + AxiosRequestConfig, + HeadersDefaults, + AxiosDefaults, + Axios, + InternalAxiosRequestConfig, + AxiosResponse, +} from 'axios' +import type { AnyFunc } from '@/types/modules/utils' + +export type AxiosHeaderValue = + | AxiosHeaders + | string + | string[] + | number + | boolean + | null + +export interface RequestHeaderOptions { + key: string + value: AxiosHeaderValue +} + +export interface AxiosInstanceExpand extends Axios { + (config: AxiosRequestConfig): Promise + (url: string, config?: AxiosRequestConfig): Promise + + getUri(config?: AxiosRequestConfig): string + request(config: AxiosRequestConfig): Promise + get(url: string, config?: AxiosRequestConfig): Promise + delete( + url: string, + config?: AxiosRequestConfig, + ): Promise + head( + url: string, + config?: AxiosRequestConfig, + ): Promise + options( + url: string, + config?: AxiosRequestConfig, + ): Promise + post( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise + put( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise + patch( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise + postForm( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise + putForm( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise + patchForm( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise + + defaults: Omit & { + headers: HeadersDefaults & { + [key: string]: AxiosHeaderValue + } + } +} + +export type RequestInterceptorConfig = InternalAxiosRequestConfig + +export type ResponseInterceptorConfig = AxiosResponse + +export interface ImplementQueue { + implementRequestInterceptorArray: AnyFunc[] + implementResponseInterceptorArray: AnyFunc[] +} + +export interface ErrorImplementQueue { + implementRequestInterceptorErrorArray: AnyFunc[] + implementResponseInterceptorErrorArray: AnyFunc[] +} + +export type BeforeFetchFunction< + T = RequestInterceptorConfig | ResponseInterceptorConfig, +> = (ins: K, mode: string) => void + +export type FetchType = 'ok' | 'error' + +export type FetchErrorFunction = ( + error: K, + mode: string, +) => void + +export interface AxiosFetchInstance { + requestInstance: RequestInterceptorConfig | null + responseInstance: ResponseInterceptorConfig | null +} + +export interface AxiosFetchError { + requestError: T | null + responseError: T | null +} diff --git a/src/components/AppComponents/AppAvatar/index.scss b/src/components/AppComponents/AppAvatar/index.scss new file mode 100644 index 00000000..cce24633 --- /dev/null +++ b/src/components/AppComponents/AppAvatar/index.scss @@ -0,0 +1,7 @@ +.app-avatar { + cursor: var(--app-avatar-cursor); + + & .app-avatar__name { + font-weight: 500; + } +} diff --git a/src/components/AppComponents/AppAvatar/index.tsx b/src/components/AppComponents/AppAvatar/index.tsx new file mode 100644 index 00000000..777597a4 --- /dev/null +++ b/src/components/AppComponents/AppAvatar/index.tsx @@ -0,0 +1,88 @@ +/** + * + * @author Ray + * + * @date 2023-05-31 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 系统管理员头像与名称 + * + * 头像展示基于 naive ui Avatar 组件, 继承该组件所有属性与方法 + * 默认读取本地 session catch 缓存 + */ + +import './index.scss' + +import { NAvatar, NSpace } from 'naive-ui' + +import { avatarProps, spaceProps } from 'naive-ui' +import { APP_CATCH_KEY } from '@/appConfig/appConfig' +import { getStorage } from '@/utils/cache' + +import type { PropType } from 'vue' +import type { AvatarProps, SpaceProps } from 'naive-ui' +import type { SigninCallback } from '@/store/modules/signin/type' + +const AppAvatar = defineComponent({ + name: 'AppAvatar', + props: { + ...avatarProps, + ...spaceProps, + cursor: { + type: String, + default: 'auto', + }, + spaceSize: { + type: [String, Number] as PropType, + default: 'medium', + }, + avatarSize: { + type: [String, Number] as PropType, + default: 'medium', + }, + }, + setup(props) { + const signin = getStorage(APP_CATCH_KEY.signin) + const cssVars = computed(() => { + const vars = { + '--app-avatar-cursor': props.cursor, + } + + return vars + }) + + return { + signin, + cssVars, + } + }, + render() { + return ( + + +
{this.signin?.name}
+
+ ) + }, +}) + +export default AppAvatar diff --git a/src/components/AppComponents/AppLockScreen/appLockVar.ts b/src/components/AppComponents/AppLockScreen/appLockVar.ts new file mode 100644 index 00000000..eb145d25 --- /dev/null +++ b/src/components/AppComponents/AppLockScreen/appLockVar.ts @@ -0,0 +1,36 @@ +/** + * + * @author Ray + * + * @date 2023-06-20 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 统一管理是否处于锁屏状态 + * + * 可以根据后台接口进行替换该变量, 只要是一个响应式的变量值即可 + */ + +const appLockScreen = useStorage('isAppLockScreen', false, sessionStorage, { + mergeDefaults: true, +}) + +const useAppLockScreen = () => { + const setLockAppScreen = (bool: boolean) => { + appLockScreen.value = bool + } + + const getLockAppScreen = () => appLockScreen.value + + return { + setLockAppScreen, + getLockAppScreen, + } +} + +export default useAppLockScreen diff --git a/src/components/AppComponents/AppLockScreen/components/LockScreen/index.tsx b/src/components/AppComponents/AppLockScreen/components/LockScreen/index.tsx new file mode 100644 index 00000000..473d5042 --- /dev/null +++ b/src/components/AppComponents/AppLockScreen/components/LockScreen/index.tsx @@ -0,0 +1,91 @@ +/** + * + * @author Ray + * + * @date 2023-06-20 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** 锁屏界面 */ + +import { NInput, NForm, NFormItem, NButton, NSpace } from 'naive-ui' +import AppAvatar from '@/components/AppComponents/AppAvatar/index' + +import { useSetting } from '@/store' +import useAppLockScreen from '@/components/AppComponents/AppLockScreen/appLockVar' +import { + rules, + useCondition, + autoFouceInput, +} from '@/components/AppComponents/AppLockScreen/hook' + +import type { FormInst, InputInst } from 'naive-ui' + +const LockScreen = defineComponent({ + name: 'LockScreen', + setup() { + const formInstRef = ref(null) + const inputInstRef = ref(null) + + const { setLockAppScreen } = useAppLockScreen() + const { changeSwitcher } = useSetting() + + const state = reactive({ + lockCondition: useCondition(), + }) + + /** 锁屏 */ + const lockScreen = () => { + formInstRef.value?.validate((error) => { + if (!error) { + setLockAppScreen(true) + changeSwitcher(true, 'lockScreenSwitch') + + state.lockCondition = useCondition() + } + }) + } + + autoFouceInput(inputInstRef) + + return { + ...toRefs(state), + lockScreen, + formInstRef, + inputInstRef, + } + }, + render() { + return ( +
+ + + + + + + 锁屏 + + +
+ ) + }, +}) + +export default LockScreen diff --git a/src/components/AppComponents/AppLockScreen/components/UnlockScreen/index.tsx b/src/components/AppComponents/AppLockScreen/components/UnlockScreen/index.tsx new file mode 100644 index 00000000..e96d8722 --- /dev/null +++ b/src/components/AppComponents/AppLockScreen/components/UnlockScreen/index.tsx @@ -0,0 +1,156 @@ +/** + * + * @author Ray + * + * @date 2023-06-20 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** 解锁界面 */ + +import { NInput, NForm, NFormItem, NButton, NSpace } from 'naive-ui' +import AppAvatar from '@/components/AppComponents/AppAvatar/index' + +import dayjs from 'dayjs' +import { useSetting, useSignin } from '@/store' +import { + rules, + useCondition, + autoFouceInput, +} from '@/components/AppComponents/AppLockScreen/hook' +import useAppLockScreen from '@/components/AppComponents/AppLockScreen/appLockVar' + +import type { FormInst, InputInst } from 'naive-ui' + +const UnlockScreen = defineComponent({ + name: 'UnlockScreen', + setup() { + const formRef = ref(null) + const inputInstRef = ref(null) + + const { logout } = useSignin() + const { changeSwitcher } = useSetting() + const { setLockAppScreen } = useAppLockScreen() + + const HH_MM_FORMAT = 'HH:mm' + const AM_PM_FORMAT = 'A' + const YY_MM_DD_FORMAT = 'YY年MM月DD日' + const DDD_FORMAT = 'ddd' + + const state = reactive({ + lockCondition: useCondition(), + HH_MM: dayjs().format(HH_MM_FORMAT), + AM_PM: dayjs().locale('en').format(AM_PM_FORMAT), + YY_MM_DD: dayjs().format(YY_MM_DD_FORMAT), + DDD: dayjs().format(DDD_FORMAT), + }) + const dayInterval = setInterval(() => { + state.HH_MM = dayjs().format(HH_MM_FORMAT) + state.AM_PM = dayjs().format(AM_PM_FORMAT) + }, 6_000) + const yearInterval = setInterval(() => { + state.YY_MM_DD = dayjs().format(YY_MM_DD_FORMAT) + state.DDD = dayjs().format(DDD_FORMAT) + }, 86_400_000) + + /** 退出登陆并且回到登陆页 */ + const backToSignin = () => { + window.$dialog.warning({ + title: '警告', + content: '是否返回到登陆页?', + positiveText: '确定', + negativeText: '取消', + onPositiveClick: () => { + logout() + setTimeout(() => { + changeSwitcher(false, 'lockScreenSwitch') + }) + }, + }) + } + + /** 解锁 */ + const unlockScreen = () => { + formRef.value?.validate((error) => { + if (!error) { + setLockAppScreen(false) + changeSwitcher(false, 'lockScreenSwitch') + + state.lockCondition = useCondition() + } + }) + } + + onBeforeUnmount(() => { + clearInterval(dayInterval) + clearInterval(yearInterval) + }) + + return { + ...toRefs(state), + backToSignin, + unlockScreen, + formRef, + inputInstRef, + } + }, + render() { + return ( +
+
+
+
{this.HH_MM?.split(':')[0]}
+
{this.HH_MM?.split(':')[1]}
+
+
+ +
+
+ + + + + + + 返回登陆 + + + 进入系统 + + + +
+ +
+
+ ) + }, +}) + +export default UnlockScreen diff --git a/src/components/AppComponents/AppLockScreen/hook.ts b/src/components/AppComponents/AppLockScreen/hook.ts new file mode 100644 index 00000000..8db8c2e2 --- /dev/null +++ b/src/components/AppComponents/AppLockScreen/hook.ts @@ -0,0 +1,38 @@ +/** + * + * @author Ray + * + * @date 2023-06-20 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import type { InputInst } from 'naive-ui' +import type { Ref } from 'vue' + +/** 统一的校验锁屏密码校验规则 */ +export const rules = { + lockPassword: { + required: true, + message: '请输入正确格式密码', + min: 6, + max: 12, + trigger: ['input'], + }, +} + +/** 锁屏密码参数 */ +export const useCondition = () => { + return { + lockPassword: null, + } +} + +/** 自动获取焦点 */ +export const autoFouceInput = (inputInstRef: Ref) => { + nextTick(() => { + inputInstRef.value?.focus() + }) +} diff --git a/src/components/AppComponents/AppLockScreen/index.scss b/src/components/AppComponents/AppLockScreen/index.scss new file mode 100644 index 00000000..47777891 --- /dev/null +++ b/src/components/AppComponents/AppLockScreen/index.scss @@ -0,0 +1,68 @@ +.app-lock-screen__content { + & .app-lock-screen__input { + & button[class*="n-button"] { + width: 100%; + } + + & form[class*="n-form"] { + margin: 24px 0px; + } + } + + & .app-lock-screen__unlock { + .app-lock-screen__unlock__content { + position: relative; + width: 100%; + height: 100%; + @include flexCenter; + flex-direction: column; + + & .app-lock-screen__unlock__content-bg { + position: absolute; + width: 100%; + height: 100%; + @include flexCenter; + font-size: 220px; + gap: 80px; + z-index: 0; + + & .left, + & .right { + @include flexCenter; + border-radius: 30px; + background-color: #141313; + font-weight: 700; + padding: 80px; + filter: blur(4px); + } + } + + & .app-lock-screen__unlock__content-avatar { + margin-top: 5px; + color: #bababa; + font-weight: 500; + z-index: 1; + } + + & .app-lock-screen__unlock__content-input { + width: 260px; + z-index: 1; + } + + & .app-lock-screen__unlock__content-date { + position: fixed; + width: 100%; + font-size: 3rem; + text-align: center; + font-weight: 500; + bottom: 24px; + z-index: 1; + + & .current-year, + & .current-date span { + font-size: 1.5rem; + } + } + } + } +} diff --git a/src/components/AppComponents/AppLockScreen/index.tsx b/src/components/AppComponents/AppLockScreen/index.tsx new file mode 100644 index 00000000..056a7872 --- /dev/null +++ b/src/components/AppComponents/AppLockScreen/index.tsx @@ -0,0 +1,59 @@ +/** + * + * @author Ray + * + * @date 2023-05-13 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 这里没有做解锁密码校验, 只要符合校验规则值皆可 + * 可以根据需求自行更改 + */ + +import './index.scss' + +import { NModal } from 'naive-ui' +import LockScreen from './components/LockScreen' +import UnlockScreen from './components/UnlockScreen' + +import { useSetting } from '@/store' +import useAppLockScreen from '@/components/AppComponents/AppLockScreen/appLockVar' + +const AppLockScreen = defineComponent({ + name: 'AppLockScreen', + setup() { + const settingStore = useSetting() + const { lockScreenSwitch } = storeToRefs(settingStore) + + const { getLockAppScreen } = useAppLockScreen() + + return { + lockScreenSwitch, + getLockAppScreen, + } + }, + render() { + return ( + +
+ {!this.getLockAppScreen() ? : } +
+
+ ) + }, +}) + +export default AppLockScreen diff --git a/src/components/AppComponents/README.md b/src/components/AppComponents/README.md new file mode 100644 index 00000000..d8e8cafd --- /dev/null +++ b/src/components/AppComponents/README.md @@ -0,0 +1,9 @@ +## 描述 + +> 该组件包存放依赖系统数据的公共组件。 + +## 约束 + +- 该组件包仅存放与系统数据有绑定、关联的组件,纯组件或纯 UI 组件应放置于外层包中 +- 以 `App` 开头标记组件是系统组件 +- 组件应该尽量避免与其他系统组件有关联性 diff --git a/src/components/RayChart/index.scss b/src/components/RayChart/index.scss new file mode 100644 index 00000000..9205e373 --- /dev/null +++ b/src/components/RayChart/index.scss @@ -0,0 +1,7 @@ +.ray-chart { + width: var(--ray-chart-width); + height: var(--ray-chart-height); + border: none; + outline: none; + box-sizing: border-box; +} diff --git a/src/components/RayChart/index.tsx b/src/components/RayChart/index.tsx new file mode 100644 index 00000000..6dcd2a55 --- /dev/null +++ b/src/components/RayChart/index.tsx @@ -0,0 +1,503 @@ +/** + * + * 基于 `echarts` 的组件. 意在便捷的使用 `chart` 图 + * + * 暂时不支持自动解析导入 `chart` 组件, 如果使用未注册的组件, 需要在顶部手动导入并且再使用 `use` 注册 + * + * 预引入: 柱状图, 折线图, 饼图, k线图, 散点图等 + * 预引入: 提示框, 标题, 直角坐标系, 数据集, 内置数据转换器等 + * + * 如果需要大批量数据渲染, 可以通过获取实例后阶段性调用 `setOption` 方法注入数据 + * + * 该组件会在卸载组件时, 自动释放资源 + * + * 注意: 尽量别一次性倒入全部 `chart` 会造成打包体积异常大 + */ + +import './index.scss' + +import * as echarts from 'echarts/core' // `echarts` 核心模块 +import { + TitleComponent, + TooltipComponent, + GridComponent, + DatasetComponent, + TransformComponent, + LegendComponent, + ToolboxComponent, + AriaComponent, +} from 'echarts/components' // 提示框, 标题, 直角坐标系, 数据集, 内置数据转换器等组件(组件后缀都为 `Component`) +import { + BarChart, + LineChart, + PieChart, + CandlestickChart, + ScatterChart, + PictorialBarChart, +} from 'echarts/charts' // 系列类型(后缀都为 `SeriesOption`) +import { LabelLayout, UniversalTransition } from 'echarts/features' // 标签自动布局, 全局过渡动画等特性 +import { CanvasRenderer } from 'echarts/renderers' // `echarts` 渲染器 + +import { useSetting } from '@/store' +import { cloneDeep, debounce } from 'lodash-es' +import { on, off, addStyle, completeSize } from '@/utils/element' + +import type { PropType } from 'vue' +import type { EChartsInstance } from '@/types/modules/component' +import type { AnyFunc } from '@/types/modules/utils' + +export type AutoResize = + | boolean + | { + width: number + height: number + } + +export interface LoadingOptions { + text: string // 文本内容 + color: string // 颜色 + textColor: string // 字体颜色 + maskColor: string // 遮罩颜色 + zlevel: number // 水平 + fontSize: number // 字体大小 + showSpinner: boolean // 是否显示旋转动画(`spinner`) + spinnerRadius: number // 旋转动画(`spinner`)的半径 + lineWidth: number // 旋转动画(`spinner`)的线宽 + fontWeight: string // 字体粗细 + fontStyle: string // 字体风格 + fontFamily: string // 字体系列 +} + +export type ChartTheme = 'dark' | '' | object + +/** + * + * @returns LoadingOptions + * + * 为了方便使用加载动画, 写了此方法, 虽然没啥用 + */ +export const loadingOptions = (options?: LoadingOptions) => + Object.assign( + {}, + { + text: 'loading', + color: '#c23531', + textColor: '#000', + maskColor: 'rgba(255, 255, 255, 0.9)', + zlevel: 0, + fontSize: 12, + showSpinner: true, + spinnerRadius: 10, + lineWidth: 5, + fontWeight: 'normal', + fontStyle: 'normal', + fontFamily: 'sans-serif', + }, + options, + ) + +const RayChart = defineComponent({ + name: 'RayChart', + props: { + width: { + /** + * + * chart 容器初始化宽度 + * + * 如果未能继承宽度, 则会以 200px 宽度填充 + */ + type: String, + default: '100%', + }, + height: { + /** + * + * chart 容器初始化高度 + * + * 如果未能继承高度, 则会以 200px 宽度填充 + */ + type: String, + default: '100%', + }, + autoResize: { + /** + * + * `chart` 是否跟随窗口尺寸变化自动变化 + * + * 如果为对象, 则可以指定其变化尺寸, 实现图表大小不等于容器大小的效果 + */ + type: [Boolean, Object] as PropType, + default: true, + }, + canvasRender: { + /** + * + * `chart` 渲染器, 默认使用 `canvas` + * + * 考虑到打包体积与大多数业务场景缘故, 暂时移除 `SVGRenderer` 渲染器的默认导入 + */ + type: Boolean, + default: true, + }, + showAria: { + /** + * + * 是否开启 `chart` 无障碍访问 + * + * 此选项会覆盖 `options` 中的 `aria` 配置 + */ + type: Boolean, + default: false, + }, + options: { + type: Object as PropType, + default: () => ({}), + }, + success: { + /** + * + * 返回 chart 实例 + * + * 渲染成功回调函数 + * + * () => EChartsInstance + */ + type: Function, + default: () => ({}), + }, + error: { + /** + * + * 渲染失败回调函数 + * + * () => void + */ + type: Function, + default: () => ({}), + }, + theme: { + type: [String, Object] as PropType, + default: '', + }, + autoChangeTheme: { + /** + * + * 是否自动跟随模板主题切换 + * + * 如果开启此属性, 则会覆盖 `theme` 属性 + * + * 注意: 这个属性重度依赖此模板, 所以默认不开启. 并且动态切换主题有一定的性能问题 + */ + type: Boolean, + default: false, + }, + use: { + /** + * + * 拓展 `echarts` 图表 + * + * 由于官方并没有提供该类型, 手动去复刻成本过高, 故而采用 `any` + */ + type: Array, + default: () => [], + }, + watchOptions: { + /** 主动监听 options 变化 */ + type: Boolean, + default: true, + }, + loading: { + /** 加载动画 */ + type: Boolean, + default: false, + }, + loadingOptions: { + /** 配置加载动画样式 */ + type: Object as PropType, + default: () => loadingOptions(), + }, + }, + setup(props, { expose }) { + const settingStore = useSetting() + const { themeValue } = storeToRefs(settingStore) + const rayChartRef = ref() // `echart` 容器实例 + const echartInstanceRef = ref() // `echart` 拷贝实例, 解决直接使用响应式实例带来的问题 + let echartInstance: EChartsInstance // `echart` 实例 + let resizeDebounce: AnyFunc // resize 防抖方法实例 + + const cssVarsRef = computed(() => { + const cssVars = { + '--ray-chart-width': completeSize(props.width), + '--ray-chart-height': completeSize(props.height), + } + + return cssVars + }) + const modelLoadingOptions = computed(() => + loadingOptions(props.loadingOptions), + ) + + /** + * + * 注册 `echart` 组件, 图利, 渲染器等 + * + * 会自动合并拓展 `echart` 组件 + * 该方法必须在注册图表之前调用 + */ + const registerChartCore = async () => { + echarts.use([ + TitleComponent, + TooltipComponent, + GridComponent, + DatasetComponent, + TransformComponent, + LegendComponent, + ToolboxComponent, + AriaComponent, + ]) // 注册组件 + + echarts.use([ + BarChart, + LineChart, + PieChart, + CandlestickChart, + ScatterChart, + PictorialBarChart, + ]) // 注册类型 + + echarts.use([LabelLayout, UniversalTransition]) // 注册布局, 过度效果 + + // 如果业务场景中需要 `svg` 渲染器, 手动导入渲染器后使用该行代码即可(不过为了体积考虑, 移除了 SVG 渲染器) + // echarts.use([props.canvasRender ? CanvasRenderer : SVGRenderer]) + echarts.use([CanvasRenderer]) // 注册渲染器 + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + echarts.use(props.use as any[]) + } catch (e) { + console.error( + 'Error: wrong property and method passed in extend attribute', + ) + } + } + + /** + * + * @returns `chart options` + * + * 合并配置项 + * + * 如果有需要特殊全局配置的可以在此继续写... + */ + const useMergeOptions = () => { + let options = cloneDeep(props.options) + + const merge = (opts: object) => Object.assign({}, options, opts) + + if (props.showAria) { + options = merge({ + aria: { + enabled: true, + decal: { + show: true, + }, + }, + }) + } + + return options + } + + /** + * + * 渲染 `echart` + * + * 缓存两个实例 + * + * 直接使用响应式代理实例会出现诡异的问题, 例如 `legend` 点击时报错 + */ + const renderChart = (theme: ChartTheme) => { + /** 获取 dom 容器 */ + const element = rayChartRef.value as HTMLElement + /** 获取配置项 */ + const options = useMergeOptions() + /** 获取 dom 容器实际宽高 */ + const { height, width } = element.getBoundingClientRect() + + /** 如果高度为 0, 则以 200px 填充 */ + if (height === 0) { + addStyle(element, { + height: '200px', + }) + } + + /** 如果款度为 0, 则以 200px 填充 */ + if (width === 0) { + addStyle(element, { + width: '200px', + }) + } + + try { + /** 注册 chart */ + echartInstance = echarts.init(element, theme) + echartInstanceRef.value = echartInstance + + /** 设置 options 配置项 */ + options && echartInstance.setOption(options) + + /** 渲染成功回调 */ + props.success?.(echartInstance) + } catch (e) { + /** 渲染失败回调 */ + props.error?.() + + console.error(e) + } + } + + /** + * + * @param bool 渲染带有主题色的可视化图 + * + * 区别自动跟随模板主题切换与指定主题切换 + */ + const renderThemeChart = (bool?: boolean) => { + if (props.autoChangeTheme) { + bool ? renderChart('dark') : renderChart('') + + return void 0 + } + + if (!props.theme) { + renderChart('') + } + } + + /** + * + * 销毁 `chart` 实例, 释放资源 + */ + const destroyChart = () => { + if (echartInstance) { + echartInstance.clear() + echartInstance.dispose() + } + } + + /** 重置 echarts 尺寸 */ + const resizeChart = () => { + if (echartInstance) { + echartInstance.resize() + } + } + + /** 监听全局主题变化, 然后重新渲染对应主题 echarts */ + watch( + () => [themeValue.value], + ([theme]) => { + /** + * + * Q: 为什么需要重新卸载再渲染 + * A: 因为 echarts 官方文档并未提供动态渲染方法 + * A: 虽然原型上有 setTheme 方法, 但是官方标记是仅限于在类 ECharts 中访问 + */ + if (props.autoChangeTheme) { + destroyChart() + + renderThemeChart(theme) + } + }, + ) + + watch( + () => props.showAria, + () => { + destroyChart() + + /** + * + * 贴花跟随主题渲染 + * + * 自动跟随模板主题或者指定主题皆可 + */ + if (props.autoChangeTheme || props.theme) { + themeValue.value ? renderChart('dark') : renderChart('') + } else { + renderChart('') + } + }, + ) + + /** 显示/隐藏加载动画 */ + watch( + () => props.loading, + (newData) => { + newData + ? echartInstance?.showLoading(modelLoadingOptions.value) + : echartInstance?.hideLoading() + }, + ) + + /** 监听 options 变化 */ + if (props.watchOptions) { + watch( + () => props.watchOptions, + () => { + /** 重新组合 options */ + const options = useMergeOptions() + + /** 如果 options 发生变动更新 echarts */ + echartInstance?.setOption(options) + }, + ) + } + + onBeforeMount(async () => { + /** 注册 echarts 组件与渲染器 */ + await registerChartCore() + }) + + onMounted(() => { + nextTick(() => { + /** 注册 echarts */ + if (props.autoChangeTheme) { + renderThemeChart(themeValue.value) + } else { + props.theme ? renderChart('dark') : renderChart('') + } + + /** 注册事件 */ + if (props.autoResize) { + resizeDebounce = debounce(resizeChart, 500) + + on(window, 'resize', resizeDebounce) + } + }) + }) + + onBeforeUnmount(() => { + /** 卸载 echarts */ + destroyChart() + /** 卸载事件柄 */ + off(window, 'resize', resizeDebounce) + }) + + expose({ + echart: echartInstanceRef, + }) + + return { + rayChartRef, + cssVarsRef, + echartInstance: echartInstanceRef, + } + }, + render() { + return ( +
+ ) + }, +}) + +export default RayChart diff --git a/src/components/RayCollapseGrid/index.ts b/src/components/RayCollapseGrid/index.ts new file mode 100644 index 00000000..bbe54ee2 --- /dev/null +++ b/src/components/RayCollapseGrid/index.ts @@ -0,0 +1,3 @@ +import RayCollapseGrid from './src/index' + +export default RayCollapseGrid diff --git a/src/components/RayCollapseGrid/src/index.scss b/src/components/RayCollapseGrid/src/index.scss new file mode 100644 index 00000000..5bf1654c --- /dev/null +++ b/src/components/RayCollapseGrid/src/index.scss @@ -0,0 +1,20 @@ +.ray-collapse-grid { + box-sizing: border-box; + + & .collapse-icon { + height: 100%; + display: flex; + align-items: center; + cursor: pointer; + transition: color 0.3s var(--r-bezier); + + > .collapse-icon--arrow { + margin-left: 0.5em; + transition: color 0.3s var(--r-bezier), transform 0.3s var(--r-bezier); + + &.collapse-icon--arrow__expanded { + transform: rotate(180deg); + } + } + } +} diff --git a/src/components/RayCollapseGrid/src/index.tsx b/src/components/RayCollapseGrid/src/index.tsx new file mode 100644 index 00000000..274cde56 --- /dev/null +++ b/src/components/RayCollapseGrid/src/index.tsx @@ -0,0 +1,95 @@ +/** + * + * @author Ray + * + * @date 2022-12-27 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +import { collapseGridProps } from './props' + +import { NCard, NGrid, NGridItem, NSpace } from 'naive-ui' +import RayIcon from '@/components/RayIcon' + +const RayCollapseGrid = defineComponent({ + name: 'RayCollapseGrid', + props: collapseGridProps, + emits: ['updateValue'], + setup(props, { emit }) { + const modelCollapsed = ref(props.value) + + const handleCollapse = () => { + modelCollapsed.value = !modelCollapsed.value + + emit('updateValue', modelCollapsed.value) + } + + const CollapseIcon = () => ( +
+ + {modelCollapsed.value + ? props.collapseToggleText[0] + : props.collapseToggleText[1]} + + +
+ ) + + return { + modelCollapsed, + handleCollapse, + CollapseIcon, + } + }, + render() { + return ( + + {{ + default: () => ( + + {this.$slots.default?.()} + + + {this.$slots.action?.()} + {this.CollapseIcon()} + + + + ), + }} + + ) + }, +}) + +export default RayCollapseGrid + +/** + * + * + * + * 可折叠操作栏 + * + * 可以结合表单或者表格使用 + * + * 该组件完全基于 `NGrid` `NGridItem` 实现, 所以需要在使用该组件时使用 `NGridItem` 包裹元素 + */ diff --git a/src/components/RayCollapseGrid/src/props.ts b/src/components/RayCollapseGrid/src/props.ts new file mode 100644 index 00000000..4ae28dac --- /dev/null +++ b/src/components/RayCollapseGrid/src/props.ts @@ -0,0 +1,53 @@ +import { gridProps } from 'naive-ui' + +import type { PropType } from 'vue' +import type { CollapseToggleText } from './type' + +export const collapseGridProps = { + value: { + /** + * + * 是否折叠操作栏 + * + * 默认折叠 + * + * `true` 收起, `false` 展开 + */ + type: Boolean, + default: true, + }, + collapseToggleText: { + /** + * + * 自定义展开与收起文案 + * + * 默认 `['展开', '收起']` + * + * 索引第一位为展开时显示内容, 所以第二位置为收起时显示内容 + */ + type: Array as unknown as PropType, + default: () => ['展开', '收起'], + }, + bordered: { + /** + * + * 卡片边框 + * + * 默认 `false` + */ + type: Boolean, + default: false, + }, + ...gridProps, +} as const + +/** + * + * 基于 `NGird` 实现 + * + * 继承该组件所有属性和方法, + * + * `xGap` 默认 `12` + * + * `yGap` 默认 `18` + */ diff --git a/src/components/RayCollapseGrid/src/type.ts b/src/components/RayCollapseGrid/src/type.ts new file mode 100644 index 00000000..12094f2a --- /dev/null +++ b/src/components/RayCollapseGrid/src/type.ts @@ -0,0 +1 @@ +export type CollapseToggleText = [string | number, string | number] diff --git a/src/components/RayGlobalProvider/index.tsx b/src/components/RayGlobalProvider/index.tsx new file mode 100644 index 00000000..cdbc4602 --- /dev/null +++ b/src/components/RayGlobalProvider/index.tsx @@ -0,0 +1,94 @@ +/** + * + * @author Ray + * + * @date 2023-06-14 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 全局注入 naive ui 提示性组件 + * 使用该组件注册后, 可以直接通过 window.$message、window.$notification、window.$dialog、window.$loadingBar 访问 + * 但是, 使用该组件注册后, 使用 window.$notification 组件时不能更改 placement 位置(只能默认右上角弹出) + * 如果需要更改弹出位置, 需要在需要地方重新定义组件注册 + */ + +import { + NDialogProvider, + NLoadingBarProvider, + NMessageProvider, + NNotificationProvider, + NConfigProvider, + createDiscreteApi, + darkTheme, + NGlobalStyle, +} from 'naive-ui' + +import { useSetting } from '@/store' +import { naiveLocales } from '@/locales/helper' + +const GlobalProvider = defineComponent({ + name: 'GlobalProvider', + setup() { + const settingStore = useSetting() + + const modelPrimaryColorOverride = computed( + () => settingStore.primaryColorOverride, + ) + const modelThemeValue = computed(() => + settingStore.themeValue ? darkTheme : null, + ) + const localePackage = computed(() => { + const key = settingStore.localeLanguage + + return naiveLocales(key) + }) + + const { message, notification, dialog, loadingBar } = createDiscreteApi( + ['message', 'dialog', 'notification', 'loadingBar'], + { + configProviderProps: computed(() => ({ + theme: modelThemeValue.value, + })), + }, + ) + + window.$dialog = dialog // 注入 `dialog` + window.$message = message // 注入 `message` + window.$loadingBar = loadingBar // 注入 `loadingBar` + window.$notification = notification // 注入 `notification` + + return { + modelPrimaryColorOverride, + modelThemeValue, + localePackage, + } + }, + render() { + return ( + + + + + + + {this.$slots.default?.()} + + + + + + ) + }, +}) + +export default GlobalProvider diff --git a/src/components/RayIcon/index.scss b/src/components/RayIcon/index.scss new file mode 100644 index 00000000..470518e4 --- /dev/null +++ b/src/components/RayIcon/index.scss @@ -0,0 +1,20 @@ +.ray-icon { + position: relative; + width: var(--ray-icon-width); + height: var(--ray-icon-height); + border: none; + outline: none; + text-align: center; + display: inline-flex; + justify-content: center; + align-items: center; + fill: currentColor; + transform: translateZ(0); + opacity: var(--ray-icon-depth); + cursor: var(--ray-icon-cursor); + + & svg[RayIconAttribute="ray-icon"] { + width: var(--ray-icon-width); + height: var(--ray-icon-height); + } +} diff --git a/src/components/RayIcon/index.tsx b/src/components/RayIcon/index.tsx new file mode 100644 index 00000000..24da1649 --- /dev/null +++ b/src/components/RayIcon/index.tsx @@ -0,0 +1,106 @@ +/** + * + * @author Ray + * + * @date 2023-01-04 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +const RayIcon = defineComponent({ + name: 'RayIcon', + props: { + color: { + type: String, + default: 'currentColor', + }, + prefix: { + type: String, + default: 'icon', + }, + name: { + type: String, + required: true, + }, + size: { + type: [Number, String], + default: 14, + }, + width: { + type: [Number, String], + default: 0, + }, + height: { + type: [Number, String], + default: 0, + }, + customClassName: { + /** 自定义 class name */ + type: String, + default: null, + }, + depth: { + /** 图标深度 */ + type: Number, + default: 1, + }, + cursor: { + /** 鼠标指针样式 */ + type: String, + default: 'default', + }, + }, + emits: ['click'], + setup(props, ctx) { + const emit = ctx.emit + + const modelColor = computed(() => props.color) + const symbolId = computed(() => `#${props.prefix}-${props.name}`) + const cssVars = computed(() => { + const cssVar = { + '--ray-icon-width': props.width + ? props.width + 'px' + : props.size + 'px', + '--ray-icon-height': props.height + ? props.height + 'px' + : props.size + 'px', + '--ray-icon-depth': props.depth, + '--ray-icon-cursor': props.cursor, + } + + return cssVar + }) + + const handleClick = () => { + emit('click') + } + + return { + modelColor, + symbolId, + cssVars, + handleClick, + } + }, + render() { + return ( + + + + + + ) + }, +}) + +export default RayIcon diff --git a/src/components/RayIframe/index.ts b/src/components/RayIframe/index.ts new file mode 100644 index 00000000..ecea3ef2 --- /dev/null +++ b/src/components/RayIframe/index.ts @@ -0,0 +1,3 @@ +import RayIframe from './src/index' + +export default RayIframe diff --git a/src/components/RayIframe/src/index.scss b/src/components/RayIframe/src/index.scss new file mode 100644 index 00000000..1a29ca87 --- /dev/null +++ b/src/components/RayIframe/src/index.scss @@ -0,0 +1,13 @@ +.ray-iframe { + width: var(--ray-iframe-width); + height: var(--ray-iframe-height); + box-sizing: border-box; + border: var(--ray-iframe-frameborder); + + & .ray-iframe__container { + width: 100%; + height: 100%; + border: 0; + outline: 0; + } +} diff --git a/src/components/RayIframe/src/index.tsx b/src/components/RayIframe/src/index.tsx new file mode 100644 index 00000000..9171ccfa --- /dev/null +++ b/src/components/RayIframe/src/index.tsx @@ -0,0 +1,180 @@ +/** + * + * @author Ray + * + * @date 2023-06-09 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +import { NSpin } from 'naive-ui' + +import { completeSize, on, off } from '@use-utils/element' + +import type { PropType } from 'vue' +import type { SpinProps } from 'naive-ui' + +const RayIframe = defineComponent({ + name: 'RayIframe', + props: { + src: { + /** iframe url */ + type: String, + required: true, + }, + iframeWrapperClass: { + /** 自定义类名 */ + type: String, + default: null, + }, + frameborder: { + /** 边框尺寸, 0 则不显示 */ + type: Number, + default: 0, + }, + width: { + /** iframe 宽度 */ + type: [String, Number], + default: '100%', + }, + height: { + /** iframe 高度 */ + type: [String, Number], + default: '100%', + }, + allow: { + /** + * + * iframe 特征策略 + * + * ``` + * 全屏激活: allow = 'fullscreen' + * 允许跨域: allow = 'payment' + * ``` + * + * 但是该配置属性受到浏览器安全策略影响, 使用前请仔细阅读文档 + */ + type: String, + default: null, + }, + name: { + /** iframe 定位嵌入的浏览上下文的名称 */ + type: String, + default: null, + }, + title: { + /** 标识 iframe 的主要内容 */ + type: String, + default: null, + }, + success: { + /** + * + * iframe 加载成功回调 + * 返回值: iframe 对象, Event + */ + type: Function, + default: null, + }, + error: { + /** + * + * iframe 加载失败回调 + * 返回值: iframe 对象, Event + */ + type: Function, + default: null, + }, + customSpinProps: { + type: Object as PropType, + default: () => ({}), + }, + lazy: { + /** 是否延迟加载 iframe */ + type: Boolean, + default: true, + }, + }, + setup(props, { expose }) { + const cssVars = computed(() => { + const cssVar = { + '--ray-iframe-frameborder': completeSize(props.frameborder), + '--ray-iframe-width': completeSize(props.width), + '--ray-iframe-height': completeSize(props.height), + } + + return cssVar + }) + const iframeRef = ref() + const spinShow = ref(true) + + const iframeLoadSuccess = (e: Event) => { + spinShow.value = false + + props.success?.(iframeRef.value, e) + } + + const iframeLoadError = (e: Event) => { + spinShow.value = false + + props.error?.(iframeRef.value, e) + } + + const getIframeRef = () => { + const iframeEl = iframeRef.value as HTMLElement + + return iframeEl + } + + expose() + + onMounted(() => { + on(getIframeRef(), 'load', iframeLoadSuccess.bind(this)) + on(getIframeRef(), 'error', iframeLoadError) + }) + + onBeforeUnmount(() => { + off(getIframeRef(), 'load', iframeLoadSuccess) + off(getIframeRef(), 'error', iframeLoadError) + }) + + return { + cssVars, + iframeRef, + spinShow, + } + }, + render() { + return ( +
+ + {{ + ...this.$slots, + default: () => ( + + ), + }} + +
+ ) + }, +}) + +export default RayIframe diff --git a/src/components/RayLink/index.tsx b/src/components/RayLink/index.tsx new file mode 100644 index 00000000..67459166 --- /dev/null +++ b/src/components/RayLink/index.tsx @@ -0,0 +1,95 @@ +import { NAvatar, NTooltip, NSpace } from 'naive-ui' + +interface AvatarOptions { + key: string + src: string + tooltip: string + icon: string +} + +const RayLink = defineComponent({ + name: 'RayLink', + setup() { + const avatarOptions: AvatarOptions[] = [ + { + key: 'yunhome', + src: 'https://yunkuangao.me/', + tooltip: '云之家', + icon: 'https://yunkuangao.me/wp-content/uploads/2022/05/cropped-cropped-QQ%E5%9B%BE%E7%89%8720220511113928.jpg', + }, + { + key: 'yun-cloud-images', + src: 'https://yunkuangao.com/', + tooltip: '云图床', + icon: 'https://yunkuangao.me/wp-content/uploads/2022/05/cropped-cropped-QQ%E5%9B%BE%E7%89%8720220511113928.jpg', + }, + { + key: 'ray-js-note', + src: 'https://note.youdao.com/s/ObWEe2BB', + tooltip: 'Ray的前端学习笔记', + icon: 'https://usc1.contabostorage.com/c2e495d7890844d392e8ec0c6e5d77eb:image/longmao.jpeg', + }, + { + key: 'ray-js-cover', + src: 'https://note.youdao.com/s/IC8xKPdB', + tooltip: 'Ray的面试题总结', + icon: 'https://usc1.contabostorage.com/c2e495d7890844d392e8ec0c6e5d77eb:image/longmao.jpeg', + }, + { + key: 'ray-template-doc', + src: 'https://xiaodaigua-ray.github.io/ray-template-doc/', + tooltip: 'Ray Template Doc', + icon: 'https://usc1.contabostorage.com/c2e495d7890844d392e8ec0c6e5d77eb:image/longmao.jpeg', + }, + { + key: 'ray-template-doc-out', + src: 'https://ray-template.yunkuangao.com/', + tooltip: 'Ray Template Doc (国内地址)', + icon: 'https://usc1.contabostorage.com/c2e495d7890844d392e8ec0c6e5d77eb:image/longmao.jpeg', + }, + ] + + const handleLinkClick = (item: AvatarOptions) => { + window.open(item.src) + } + + return { + handleLinkClick, + avatarOptions, + } + }, + render() { + return ( + + {this.avatarOptions.map((curr) => ( + + {{ + trigger: () => ( + + ), + default: () => curr.tooltip, + }} + + ))} + + ) + }, +}) + +export default RayLink + +/** + * + * 友链组件 + * + * 这个组件用作初试模板中, 不喜欢自行删除 + */ diff --git a/src/components/RayTable/index.ts b/src/components/RayTable/index.ts new file mode 100644 index 00000000..2b2b1819 --- /dev/null +++ b/src/components/RayTable/index.ts @@ -0,0 +1,4 @@ +import RayTable from './src/index' + +export default RayTable +export type { RayTableInst } from './src/type' diff --git a/src/components/RayTable/src/components/TableAction/index.tsx b/src/components/RayTable/src/components/TableAction/index.tsx new file mode 100644 index 00000000..319352fa --- /dev/null +++ b/src/components/RayTable/src/components/TableAction/index.tsx @@ -0,0 +1,132 @@ +/** + * + * @author Ray + * + * @date 2022-12-22 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { NPopconfirm, NSpace, NButton, NPopover } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' + +export type EmitterType = 'positive' | 'negative' + +const TableAction = defineComponent({ + name: 'TableAction', + props: { + tooltip: { + /** + * + * 提示内容 + */ + type: String, + required: true, + }, + negativeText: { + /** + * + * 取消提示按钮文本内容 + * + * 默认 `取消` + */ + type: String, + default: '取消', + }, + positiveText: { + /** + * + * 确认提示按钮文本内容 + * + * 默认 `确认` + */ + type: String, + default: '确认', + }, + icon: { + /** + * + * 图标 + * + * 必须为 `icons` 中已包含的 + */ + type: String, + required: true, + }, + iconSize: { + /** + * + * 图标尺寸 + * + * 默认为 `18px` + */ + type: Number, + default: 18, + }, + popoverContent: { + type: String, + required: true, + }, + }, + emits: ['positive', 'negative'], + setup(_, { emit }) { + const showPopoconfirm = ref(false) + + const handleEmit = (type: EmitterType) => { + type === 'positive' ? emit('positive') : emit('negative') + + showPopoconfirm.value = false + } + + return { + handleEmit, + showPopoconfirm, + } + }, + render() { + return ( + + {{ + trigger: () => ( + + {{ + trigger: () => ( + + ), + default: () => this.tooltip, + action: () => ( + + + {this.negativeText} + + + {this.positiveText} + + + ), + }} + + ), + default: () => this.popoverContent, + }} + + ) + }, +}) + +export default TableAction diff --git a/src/components/RayTable/src/components/TableScreenfull/index.scss b/src/components/RayTable/src/components/TableScreenfull/index.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/components/RayTable/src/components/TableScreenfull/index.tsx b/src/components/RayTable/src/components/TableScreenfull/index.tsx new file mode 100644 index 00000000..e8d55abe --- /dev/null +++ b/src/components/RayTable/src/components/TableScreenfull/index.tsx @@ -0,0 +1,67 @@ +/** + * + * @author Ray + * + * @date 2023-03-11 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +import { NPopover } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' + +import screenfull from 'screenfull' + +import type { TableSettingProvider } from '@/components/RayTable/src/type' + +const TableScreenfull = defineComponent({ + name: 'TableScreenfull', + setup() { + const tableSettingProvider = inject( + 'tableSettingProvider', + {} as TableSettingProvider, + ) + + const rayTableUUID = computed(() => tableSettingProvider.rayTableUUID) + let currentTableIsFullscreen = screenfull.isFullscreen // 缓存当前是否处于全屏状态 + + const handleScreenfull = () => { + const el = document.getElementById(rayTableUUID.value) + + currentTableIsFullscreen = !currentTableIsFullscreen + + if (el && screenfull.isEnabled && currentTableIsFullscreen) { + screenfull.request(el) + } else { + screenfull.exit() + } + } + + return { + handleScreenfull, + } + }, + render() { + return ( + + {{ + trigger: () => ( + + ), + default: () => '全屏表格', + }} + + ) + }, +}) + +export default TableScreenfull diff --git a/src/components/RayTable/src/components/TableSetting/hook.ts b/src/components/RayTable/src/components/TableSetting/hook.ts new file mode 100644 index 00000000..5314b758 --- /dev/null +++ b/src/components/RayTable/src/components/TableSetting/hook.ts @@ -0,0 +1,19 @@ +import type { ActionOptions } from '@/components/RayTable/src/type' + +export const setupSettingOptions = (options: ActionOptions[]) => { + const arr = options.map((curr) => { + if (curr.fixed) { + curr.fixed === 'right' + ? (curr.rightFixedActivated = true) + : (curr.leftFixedActivated = true) + } + + if (curr.resizable) { + curr.resizeColumnActivated = true + } + + return curr + }) + + return arr +} diff --git a/src/components/RayTable/src/components/TableSetting/index.scss b/src/components/RayTable/src/components/TableSetting/index.scss new file mode 100644 index 00000000..3bae6b52 --- /dev/null +++ b/src/components/RayTable/src/components/TableSetting/index.scss @@ -0,0 +1,70 @@ +.ray-table__setting:hover { + transform: rotate(180deg); + transition: transform 0.3s var(--r-bezier); +} + +.table-setting__card { + padding: 12px 8px; + + & .n-card__content { + padding: 0 !important; + margin: 0 !important; + } +} + +.ray-table__setting-option--draggable { + display: grid; + grid-row-gap: 10px; + justify-self: center; + align-self: center; + + & .draggable-item { + display: flex; + align-items: center; + cursor: pointer; + padding: 8px 10px; + border-radius: 2px; + transition: background-color 0.3s var(--r-bezier); + + &.draggable-item--dark { + &:hover { + background-color: var(--ray-theme-primary-fade-color); + } + } + + &:hover { + background-color: var(--ray-theme-primary-fade-color); + + & .draggable-item__d--icon { + opacity: 1; + } + } + + & .draggable-item__d--icon { + transition: opacity 0.3s var(--r-bezier), transform 0.3s var(--r-bezier); + opacity: 0; + } + + & .draggable-item__d--icon, + & .draggable-item__icon { + padding: 5px; + outline: none; + border: none; + } + + & .draggable-item__icon { + cursor: pointer; + } + + & .draggable-item__icon { + &.draggable-item__icon--actived { + color: var(--ray-theme-primary-color); + } + } + + & .n-ellipsis { + max-width: 80px; + min-width: 80px; + } + } +} diff --git a/src/components/RayTable/src/components/TableSetting/index.tsx b/src/components/RayTable/src/components/TableSetting/index.tsx new file mode 100644 index 00000000..891e08d0 --- /dev/null +++ b/src/components/RayTable/src/components/TableSetting/index.tsx @@ -0,0 +1,233 @@ +/** + * + * @author Ray + * + * @date 2022-12-08 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 依赖 table columns 属性操作 + * + * 支持拖拽修改列顺序、动态修改列宽度、固定(锁列) + */ + +import './index.scss' + +import { NCard, NPopover, NEllipsis } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' +import VueDraggable from 'vuedraggable' + +import { setupSettingOptions } from './hook' +import { useSetting } from '@/store' + +import type { + TableSettingProvider, + ActionOptions, + FixedType, + TableSettingFixedPopoverIcon, +} from '@/components/RayTable/src/type' + +const TableSetting = defineComponent({ + name: 'TableSetting', + emits: ['columnsUpdate'], + setup(_, { emit }) { + const tableSettingProvider = inject( + 'tableSettingProvider', + {} as TableSettingProvider, + ) + const settingStore = useSetting() + + const settingOptions = ref( + setupSettingOptions(tableSettingProvider.modelColumns.value), + ) // 表格表头 + const disableDraggable = ref(true) // 拖拽开关(暂时弃用) + const { themeValue } = storeToRefs(settingStore) + + /** 拖拽结束后 */ + const handleDraggableEnd = () => { + emit('columnsUpdate', settingOptions.value) + } + + const FixedPopoverIcon = (options: TableSettingFixedPopoverIcon) => { + const { element, name, tooltip, fn, index, fixed, key } = options + + return ( + + {{ + trigger: () => ( + + ), + default: () => tooltip, + }} + + ) + } + + /** + * + * @param type 列所需固定方向 + * @param idx 当前操作栏索引位置 + * + * @remark 操作栏锁定列, 不能同时存在两种状态(互斥) + */ + const handleFixedClick = (type: FixedType, idx: number) => { + const key = `${type}FixedActivated` + const value = settingOptions.value[idx] + + if (key === 'leftFixedActivated') { + value['rightFixedActivated'] = false + } else if (key === 'rightFixedActivated') { + value['leftFixedActivated'] = false + } + + value[key] = !value[key] + + if (value[key]) { + value.fixed = type + } else { + value.fixed = undefined + } + + settingOptions.value[idx] = value + + emit('columnsUpdate', settingOptions.value) + } + + /** + * + * @param idx 索引 + * + * @remark 动态设置列宽度, 如果表格并未出现横向滚动条则不会启用拖拽修改列按钮 + */ + const handleResizeColumnClick = (idx: number) => { + const value = settingOptions.value[idx] + + value['resizeColumnActivated'] = !value['resizeColumnActivated'] + value['resizable'] = value['resizeColumnActivated'] + + settingOptions.value[idx] = value + + emit('columnsUpdate', settingOptions.value) + } + + return { + settingOptions, + handleDraggableEnd, + handleFixedClick, + disableDraggable, + FixedPopoverIcon, + handleResizeColumnClick, + themeValue, + } + }, + render() { + return ( + + {{ + trigger: () => ( + + ), + default: () => ( + + {{ + default: () => ( + + {{ + item: ({ + element, + index, + }: { + element: ActionOptions + index: number + }) => ( +
+ + + {element.title} + + {this.FixedPopoverIcon({ + element: element, + name: 'left_arrow', + tooltip: '左固定', + fn: this.handleFixedClick, + index, + fixed: 'left', + key: 'leftFixedActivated', + })} + + {{ + trigger: () => ( + + ), + default: () => '修改列宽', + }} + + {this.FixedPopoverIcon({ + element: element, + name: 'right_arrow', + tooltip: '右固定', + fn: this.handleFixedClick, + index, + fixed: 'right', + key: 'rightFixedActivated', + })} +
+ ), + }} +
+ ), + }} +
+ ), + }} +
+ ) + }, +}) + +export default TableSetting diff --git a/src/components/RayTable/src/components/TableSize/index.scss b/src/components/RayTable/src/components/TableSize/index.scss new file mode 100644 index 00000000..4fcbd871 --- /dev/null +++ b/src/components/RayTable/src/components/TableSize/index.scss @@ -0,0 +1,47 @@ +.ray-table__table-size { + padding: 0 !important; + + & .n-card__content { + padding: 0 !important; + margin: 0 !important; + + & .table-size__dropdown { + box-sizing: border-box; + padding: 4px 0; + background-color: transparent; + + & .table-size__dropdown-wrapper { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + + & .dropdown-item { + height: 34px; + line-height: 34px; + text-align: center; + cursor: pointer; + padding: 0 16px; + transition: background-color 0.3s var(--r-bezier), color 0.3s var(--r-bezier); + + &.dropdown-item--active, + &:hover { + background-color: var(--ray-theme-primary-fade-color); + color: var(--ray-theme-primary-color); + } + } + } + } + } +} + +.ray-table__table-size--dark { + @include useAppTheme("dark") { + & .table-size__dropdown-wrapper { + & .dropdown-item:hover { + background-color: var(--ray-theme-primary-fade-color); + color: var(--ray-theme-primary-color); + } + } + } +} diff --git a/src/components/RayTable/src/components/TableSize/index.tsx b/src/components/RayTable/src/components/TableSize/index.tsx new file mode 100644 index 00000000..bdd04779 --- /dev/null +++ b/src/components/RayTable/src/components/TableSize/index.tsx @@ -0,0 +1,129 @@ +/** + * + * @author Ray + * + * @date 2023-03-10 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +import { NPopover, NCard } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' + +import type { TableSettingProvider } from '@/components/RayTable/src/type' +import type { ComponentSize } from '@/types/modules/component' + +const TableSize = defineComponent({ + name: 'TableSize', + emits: ['changeSize'], + setup(_, { emit }) { + const tableSettingProvider = inject( + 'tableSettingProvider', + {} as TableSettingProvider, + ) + + const popoverShow = ref(false) + const currentSize = ref(tableSettingProvider.size) + const size = computed({ + get: () => tableSettingProvider.size, + set: (val) => { + currentSize.value = val + }, + }) + const sizeOptions = ref([ + { + label: '默认', + key: 'medium', + }, + { + label: '紧凑', + key: 'small', + }, + { + label: '宽松', + key: 'large', + }, + ]) + + const handleDropdownClick = (key: ComponentSize) => { + sizeOptions.value.forEach((curr) => { + if (curr.key === key) { + size.value = key + + popoverShow.value = false + + emit('changeSize', key) + } + }) + } + + return { + sizeOptions, + currentSize, + handleDropdownClick, + popoverShow, + } + }, + render() { + return ( + + {{ + trigger: () => ( + + {{ + trigger: () => ( + + ), + default: () => '表格密度', + }} + + ), + default: () => ( + +
+
+ {this.sizeOptions.map((curr) => ( +
+
{curr.label}
+
+ ))} +
+
+
+ ), + }} +
+ ) + }, +}) + +export default TableSize diff --git a/src/components/RayTable/src/index.scss b/src/components/RayTable/src/index.scss new file mode 100644 index 00000000..71ef89d8 --- /dev/null +++ b/src/components/RayTable/src/index.scss @@ -0,0 +1,39 @@ +@keyframes scaleScreenfull { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.3); + } + 100% { + transform: scale(1); + } +} + +.ray-table { + & .ray-table-icon { + transition: transform 0.3s var(--r-bezier); + + &:hover { + animation: scaleScreenfull 0.3s linear; + animation-direction: alternate; + } + } + + & .ray-table__setting, + & .ray-table-icon { + cursor: pointer; + outline: none; + border: none; + } + + & .n-card-header .n-card-header__main { + padding-right: var(--ray-table-header-space); + } + + & .ray-table-header-extra__space { + display: flex; + gap: 0 12px; + align-items: center; + } +} diff --git a/src/components/RayTable/src/index.tsx b/src/components/RayTable/src/index.tsx new file mode 100644 index 00000000..fa6a903a --- /dev/null +++ b/src/components/RayTable/src/index.tsx @@ -0,0 +1,328 @@ +/** + * + * @author Ray + * + * @date 2022-12-08 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * + * + * 完全继承 `NDataTable`, 该组件继承 `NDataTable Props` 属性和方法 + * + * 实现: 抬头, 操作栏, 右键菜单功能拓展, 输出 `excel`, 表格尺寸调整 + * + * 右键菜单功能, 需要同时启用 `showMenu` 与配置菜单选项才能正常使用 + * + * 可以通过设置 `action` 为 `false` 隐藏操作栏 + * + * 具体拓展 `props` 方法, 可以查看 `props.ts` 中相关注释与代码 + * + * 基于 `xlsx.js` 实现输出 `excel` + */ + +/** + * + * 为什么有些拓展功能是写在该组件内, 有些则是完全抽离出去呢... + * 好问题, 因为我一开始没想到而且我又想偷懒 + * + * 凑合凑合看吧, 等我啥时候有空再抽离出去 + */ + +import './index.scss' + +import { NDataTable, NCard, NDropdown } from 'naive-ui' +import TableSetting from './components/TableSetting/index' +import TableAction from './components/TableAction/index' +import TableSize from './components/TableSize/index' +import TableScreenfull from './components/TableScreenfull/index' + +import props from './props' +import print from 'print-js' +import { uuid } from '@use-utils/hook' +import { exportFileToXLSX } from '@use-utils/xlsx' + +import type { ActionOptions } from './type' +import type { WritableComputedRef } from 'vue' +import type { DropdownOption } from 'naive-ui' +import type { ExportExcelHeader } from '@use-utils/xlsx' +import type { DataTableInst } from 'naive-ui' +import type { ComponentSize } from '@/types/modules/component' + +const RayTable = defineComponent({ + name: 'RayTable', + props: props, + emits: ['update:columns', 'menuSelect', 'exportSuccess', 'exportError'], + setup(props, { emit, expose }) { + const rayTableInstance = ref() + + const tableUUID = uuid(16) // 表格 id, 用于打印表格 + const rayTableUUID = uuid(16) // RayTable id, 用于全屏表格 + const modelRightClickMenu = computed(() => props.rightClickMenu) + const modelColumns = computed({ + get: () => props.columns, + set: (arr) => { + emit('update:columns', arr) + }, + }) as unknown as WritableComputedRef + const menuConfig = reactive({ + x: 0, + y: 0, + showMenu: false, + }) + let prevRightClickIndex = -1 // 缓存上次点击索引位置 + const cssVars = computed(() => { + const cssVar = { + '--ray-table-header-space': props.tableHeaderSpace, + } + + return cssVar + }) + const tableSize = ref(props.size) + const tableMethods = ref>() + + /** 注入相关属性 */ + provide('tableSettingProvider', { + modelRightClickMenu, + modelColumns, + size: props.size, + rayTableUUID, + }) + + /** 拖拽更新后的表格列 */ + const handleColumnsUpdate = (arr: ActionOptions[]) => { + modelColumns.value = arr + } + + /** + * + * @param key 右键菜单当前选择 `key` + * @param option 右键菜单当前 `item` + * + * @remark (key: string | number, index: number,option: DropdownOption) => void + */ + const handleRightMenuSelect = ( + key: string | number, + option: DropdownOption, + ) => { + emit('menuSelect', key, prevRightClickIndex, option) + + menuConfig.showMenu = false + } + + /** + * + * @param arr 表格当前行 + * @param idx 表格当前索引位置 + * @returns 自定义属性集 + * + * @remark 集成右键菜单属性, 会自动拦截右键方法, 会自动合并自定义行属性 + */ + const handleRowProps = (arr: ActionOptions, idx: number) => { + const interceptRowProps = props.rowProps?.(arr, idx) + + return { + ...interceptRowProps, + onContextmenu: (e: MouseEvent) => { + e.preventDefault() + + prevRightClickIndex = idx + + menuConfig.showMenu = false + + nextTick().then(() => { + menuConfig.showMenu = true + + menuConfig.x = e.clientX + menuConfig.y = e.clientY + }) + }, + } + } + + /** + * + * 导出表格数据为 `excel` + * + * 基于 `xlsx` + * + * 按需导入 `xlsx` 减少体积, 不依赖传统 `file save` 插件导出方式 + */ + const handleExportPositive = async () => { + if (props.data.length && props.columns.length) { + try { + await exportFileToXLSX( + props.data, + props.columns as ExportExcelHeader[], + { + filename: props.exportFilename, + }, + ) + + emit('exportSuccess') + } catch (e) { + emit('exportError') + } + } + } + + /** + * + * 打印输出表格内容 + * + * 默认配置按照 `print-js` 配置 + * + * 会自动合并自定义配置项 + * + * 受到 `print-js` 限制有些样式是无法打印输出的 + */ + const handlePrintPositive = () => { + const options = Object.assign({}, props.printOptions, { + printable: tableUUID, + type: props.printType, + documentTitle: props.printOptions.documentTitle + ? props.printOptions.documentTitle + : '表格', + }) + + print(options) + } + + /** 更新后的表格尺寸 */ + const handleChangeTableSize = (size: ComponentSize) => { + tableSize.value = size + } + + const registerRayTableMethods = (ins: DataTableInst) => { + const { + clearFilters, + clearSorter, + filters, + page, + scrollTo, + sort, + filter, + } = ins + + tableMethods.value = { + clearFilters, + clearSorter, + filters, + page, + scrollTo, + sort, + filter, + } + } + + expose({ + tableMethods: computed(() => tableMethods.value), + }) + + onMounted(() => { + registerRayTableMethods(rayTableInstance.value as DataTableInst) + }) + + return { + tableUUID, + rayTableUUID, + handleColumnsUpdate, + ...toRefs(menuConfig), + handleRowProps, + handleRightMenuSelect, + handleExportPositive, + handlePrintPositive, + cssVars, + handleChangeTableSize, + tableSize, + rayTableInstance, + } + }, + render() { + return ( + + {{ + default: () => ( + <> + + {{ + ...this.$slots, + }} + + {this.showMenu ? ( + // 右键菜单 + (this.showMenu = false)} + onSelect={this.handleRightMenuSelect.bind(this)} + /> + ) : ( + '' + )} + + ), + header: () => this.title, + 'header-extra': () => + this.action ? ( +
+ {/* 打印输出操作 */} + + {/* 输出为Excel表格 */} + + {/* 表格尺寸调整 */} + + {/* 全屏表格 */} + + {/* 表格列操作 */} + +
+ ) : ( + '' + ), + footer: () => this.$slots.tableFooter?.(), + }} +
+ ) + }, +}) + +export default RayTable diff --git a/src/components/RayTable/src/props.ts b/src/components/RayTable/src/props.ts new file mode 100644 index 00000000..7de02339 --- /dev/null +++ b/src/components/RayTable/src/props.ts @@ -0,0 +1,219 @@ +/** + * + * @author Ray + * + * @date 2022-12-08 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { dataTableProps } from 'naive-ui' + +import type { PropType, VNode, VNodeChild } from 'vue' +import type { DropdownMixedOption } from './type' +import type PrintConfiguration from 'print-js' + +const rayTableProps = { + ...dataTableProps, // 继承 `data table props` + rightClickMenu: { + /** + * + * 表格右键菜单, 基于 `NDropdown` 实现 + * + * 如果菜单内容长度为 `0` 则不会渲染 + * + * 只需要传入对应的菜单配置项, 即可自动开启右键菜单功能 + */ + type: Array as PropType, + default: () => [], + }, + title: { + /** + * + * 表格标题 + * + * 可以自定义渲染 + */ + type: [String, Object] as PropType, + default: '', + }, + action: { + /** + * + * 是否开启操作栏 + * + * 默认开启 + */ + type: Boolean, + default: true, + }, + actionExtra: { + /** + * + * 自定义拓展操作栏 + * + * 暂时不开放 + */ + type: Object as PropType, + default: () => ({}), + }, + showMenu: { + /** + * + * 是否展示右键菜单 + * + * 默认启用 + */ + type: Boolean, + default: true, + }, + exportTooltip: { + /** + * + * 导出表格提示 + */ + type: String, + default: '是否导出为Excel表格?', + }, + exportType: { + /** + * + * 导出类型 + * + * 默认为 `xlsx` + * + * 暂时只支持导出为 `xlsx` + */ + type: String, + default: 'xlsx', + }, + exportPositiveText: { + /** + * + * 导出确认按钮文字 + * + * 默认为 `确认` + */ + type: String, + default: '确认', + }, + exportNegativeText: { + /** + * + * 导出取消按钮文字 + * + * 默认为 `取消` + */ + type: String, + default: '取消', + }, + exportFilename: { + /** + * + * 导出表格名称 + */ + type: String, + default: '', + }, + printPositiveText: { + /** + * + * 打印确认按钮文字 + * + * 默认为 `确认` + */ + type: String, + default: '确认', + }, + printNegativeText: { + /** + * + * 打印取消按钮文字 + * + * 默认为 `取消` + */ + type: String, + default: '取消', + }, + printTooltip: { + /** + * + * 打印表格提示 + */ + type: String, + default: '是否打印该表格?', + }, + printType: { + /** + * + * 打印输出类型: 'pdf' | 'html' | 'image' | 'json' + * + * 默认为 `html` + */ + type: String as PropType, + default: 'html', + }, + printOptions: { + /** + * + * `print-js` 打印配置项 + * + * 会自动过滤: `printable`, 'type' + */ + type: Object as PropType< + Omit + >, + default: () => ({}), + }, + printIcon: { + /** + * + * 打印按钮自定义图标名称 + * + * 需要结合 `RayIcon` 组件使用 + * + * 如果需要自定义图标, 则需要在 `src/icons` 中添加后使用 + */ + type: String, + default: 'print', + }, + exportExcelIcon: { + /** + * + * 导出为表格按钮自定义图标名称 + * + * 需要结合 `RayIcon` 组件使用 + * + * 如果需要自定义图标, 则需要在 `src/icons` 中添加后使用 + */ + type: String, + default: 'export_excel', + }, + tableHeaderSpace: { + /** + * + * 表格头部操作栏, 主要操作栏与额外操作栏之间间隔 + */ + type: String, + default: '10px', + }, + bordered: { + /** + * + * 表格边框 + */ + type: Boolean, + default: false, + }, +} as const + +export default rayTableProps + +/** + * + * `Ray Table Props` + * + * 继承 `Naive UI Data Table` + */ diff --git a/src/components/RayTable/src/type.ts b/src/components/RayTable/src/type.ts new file mode 100644 index 00000000..65dd12d3 --- /dev/null +++ b/src/components/RayTable/src/type.ts @@ -0,0 +1,78 @@ +import type { + DropdownOption, + DropdownGroupOption, + DropdownDividerOption, + DropdownRenderOption, + DataTableBaseColumn, + DataTableInst, +} from 'naive-ui' +import type { ComputedRef, WritableComputedRef, VNode } from 'vue' +import type { ComponentSize } from '@/types/modules/component' + +export interface ActionOptions extends DataTableBaseColumn { + leftFixedActivated?: boolean // 向左固定 + rightFixedActivated?: boolean // 向右固定 + resizeColumnActivated?: boolean // 拖拽表格列 +} + +export type FixedType = 'left' | 'right' | undefined + +export interface TableSettingFixedPopoverIcon { + element: ActionOptions + name: string + tooltip: string + fn: Function + index: number + fixed: FixedType + key: 'leftFixedActivated' | 'rightFixedActivated' +} + +export type DropdownMixedOption = + | DropdownOption + | DropdownGroupOption + | DropdownDividerOption + | DropdownRenderOption + +export type SettingOptions = WritableComputedRef + +export type RightClickMenu = ComputedRef + +export interface TableSettingProvider { + modelRightClickMenu: RightClickMenu + modelColumns: SettingOptions + size: ComponentSize + rayTableUUID: string +} + +export interface ExportExcelProvider { + exportTooltip: string + exportType: string + exportPositiveText: string + exportNegativeText: string + exportFilename: string +} + +export type ColumnKey = string | number + +declare type VNodeChildAtom = + | VNode + | string + | number + | boolean + | null + | undefined + | void + +export declare type VNodeArrayChildren = Array< + VNodeArrayChildren | VNodeChildAtom +> + +export declare type VNodeChild = VNodeChildAtom | VNodeArrayChildren + +export declare type TableColumnTitle = + | string + | ((column: DataTableBaseColumn) => VNodeChild) + +export declare type RayTableInst = { + tableMethods: Omit +} diff --git a/src/components/RayTransitionComponent/TransitionComponent.vue b/src/components/RayTransitionComponent/TransitionComponent.vue new file mode 100644 index 00000000..de49154b --- /dev/null +++ b/src/components/RayTransitionComponent/TransitionComponent.vue @@ -0,0 +1,46 @@ + + diff --git a/src/dayjs/index.ts b/src/dayjs/index.ts new file mode 100644 index 00000000..335ea102 --- /dev/null +++ b/src/dayjs/index.ts @@ -0,0 +1,39 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import dayjs from 'dayjs' +import { DEFAULT_DAYJS_LOCAL, DAYJS_LOCAL_MAP } from '@/appConfig/localConfig' +import 'dayjs/locale/zh-cn' + +import type { DayjsLocal } from './type' + +export const setupDayjs = () => { + dayjs.locale(DEFAULT_DAYJS_LOCAL) +} + +/** + * + * dayjs hook + * + * 说明: + * - locale: 切换 dayjs 语言配置 + */ +export const useDayjs = () => { + const locale = (key: DayjsLocal) => { + const mapkey = DAYJS_LOCAL_MAP[key] + + mapkey ? dayjs.locale(mapkey) : dayjs.locale(DEFAULT_DAYJS_LOCAL) + } + + return { + locale, + } +} diff --git a/src/dayjs/type.ts b/src/dayjs/type.ts new file mode 100644 index 00000000..c01cfa34 --- /dev/null +++ b/src/dayjs/type.ts @@ -0,0 +1,6 @@ +export type DayjsLocal = 'zh-cn' | 'en' + +export interface DayjsLocalMap { + 'zh-CN': 'zh-cn' + 'en-US': 'en' +} diff --git a/src/directives/README.md b/src/directives/README.md new file mode 100644 index 00000000..ac6962fd --- /dev/null +++ b/src/directives/README.md @@ -0,0 +1,38 @@ +## 说明 + +> 全局自定义指令入口。 + +## 规范 + +- 指令应该为全局的通用性指令 +- 如果指令需要与系统的数据进行关联,应该注意数据的管理与指令注册使用时机 + +## 添加指令说明 + +- 模板视 modules 中每一个文件包为一个模板的指令(全局),并且每个文件包的名称,也被视为该指令名称 +- 添加文件包后,强制要求 index.ts 为指令的输出文件名 +- modules 包中所有指令都会被自动合并到模板中 + +```ts +/** + * + * 示例添加 demo 指令 + */ + +// 1. modules 中添加文件包 +// 2. modules/demo 目录下创建 index.ts 文件 +// 3. 进行自定义指令开发 + +import type { Directive } from 'vue' +import type { RoleBindingValue } from './type' + +const demoDirective: Directive = { + beforeMount: (el, binding) => { + console.log(el, binding) + }, +} + +export default demoDirective + +// 4. 按照上述步骤执行后,会自动在模板中创建一个 v-demo 的指令供全局使用 +``` diff --git a/src/directives/README_DIR.md b/src/directives/README_DIR.md new file mode 100644 index 00000000..382f1a0b --- /dev/null +++ b/src/directives/README_DIR.md @@ -0,0 +1,215 @@ +## 全局自定义指令 + +### v-copy + +- 参数类型: any(参数会强制被 String 方法强制转换) +- 默认值: '' + +#### 示例 + +```tsx +import { NSpace, NCard, NInput, NInputGroup, NButton, NSwitch } from 'naive-ui' + +const Demo = defineComponent({ + name: 'Demo', + setup() { + const dmeoCopyValue = ref('hello copy') + + return { + dmeoCopyValue, + } + }, + render() { + return ( + + + 复制 + + ) + }, +}) +``` + +### v-debounce + +- 参数类型: DebounceBindingOptions +- 默认值: + +```ts +{ + trigger: 'click', + wait: 500, + options: null +} +``` + +#### 示例 + +```tsx +import { NSpace, NCard, NInput, NInputGroup, NButton, NSwitch } from 'naive-ui' + +const Demo = defineComponent({ + name: 'Demo', + setup() { + const demoValue = ref(0) + + const updateDemoValue = () => { + demoValue.value++ + } + + return { + demoValue, + updateDemoValue, + } + }, + render() { + return ( + + + 点击执行 + +

我执行了{this.demoValue}次

+

该方法将延迟 1s 执行

+
+ ) + }, +}) +``` + +### v-throttle + +- 参数类型: ThrottleBindingOptions +- 默认值: + +```ts +{ + trigger: 'click', + wait: 500, + options: null +} +``` + +#### 示例 + +```tsx +import { NSpace, NCard, NInput, NInputGroup, NButton, NSwitch } from 'naive-ui' + +const Demo = defineComponent({ + name: 'Demo', + setup() { + const demoValue = ref(0) + + const updateDemoValue = () => { + demoValue.value++ + } + + return { + demoValue, + updateDemoValue, + } + }, + render() { + return ( + + + 点击执行 + +

我执行了{this.demoValue}次

+

该方法 1s 内仅会执行一次

+
+ ) + }, +}) +``` + +### v-disabled + +- 参数类型: boolean +- 默认值: false + +#### Tip + +> 该指令基于 css 层面进行禁用操作,如果元素含有 `disabled` 属性方法,会尝试使用原生 `disabled` 属性。对于组件库而言,本身就提供了丰富的 `disabled props`,所以应该是优先使用组件自带属性。 + +#### 示例 + +```tsx +import { + NSpace, + NCard, + NInput, + NInputGroup, + NButton, + NSwitch, + NForm, + NFormItem, +} from 'naive-ui' + +const Demo = defineComponent({ + name: 'Demo', + setup() { + const disabledValue = ref(false) + + return { + disabledValue, + } + }, + render() { + return ( + + + {{ + checked: () => '取消', + unchecked: () => '禁用', + }} + +

+ 该指令会强制禁用(通过 css 层面)禁用元素交互。但是 naive ui + 组件提供了完整的 disabled + 属性,所以在组件库有禁用需求时,直接调用组件库 disabled 属性即可 +

+ + + +
+ + +
+
+
+ + +

我是可以被禁用的文本内容

+
+
+ + + 按钮 + + + + + + + + +
+
+ ) + }, +}) +``` diff --git a/src/directives/helper/combine.ts b/src/directives/helper/combine.ts new file mode 100644 index 00000000..ea0647ab --- /dev/null +++ b/src/directives/helper/combine.ts @@ -0,0 +1,33 @@ +/** + * + * @author Ray + * + * @date 2023-06-24 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import type { DirectiveModules } from '@/directives/type' + +export const combineDirective = < + T extends Record, + K extends keyof T, +>( + directiveModules: T, +) => { + const directives = Object.keys(directiveModules).reduce((pre, curr) => { + if (directiveModules[curr]?.default) { + const value = directiveModules[curr]?.default + + pre[curr] = value + + return pre + } else { + throw new Error('directiveModules[curr]?.default is undefined') + } + }, {} as Record) + + return directives +} diff --git a/src/directives/index.ts b/src/directives/index.ts new file mode 100644 index 00000000..a1495b2c --- /dev/null +++ b/src/directives/index.ts @@ -0,0 +1,49 @@ +/** + * + * @author Ray + * + * @date 2023-06-24 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { combineDirective } from './helper/combine' +import { forIn } from 'lodash-es' +import { isValueType } from '@/utils/hook' + +import type { App } from 'vue' +import type { DirectiveModules } from '@/directives/type' + +/** + * + * 初始化全局自定义指令 + * + * 该方法会将 modules 下每个文件夹视为一个指令 + * 并且会将文件夹名称识别为指令名称 + * 每个文件下的 index.ts 文件视为每个指令的入口(也就是指令的处理逻辑, 需要暴露出一个 Directive 类型的对象) + */ +export const setupDirective = (app: App) => { + // 获取 modules 包下所有的 index.ts 文件 + const directiveRawModules: Record = + import.meta.glob('./modules/**/index.ts', { + eager: true, + }) + // 将所有的包提取出来(./modules/[file-name]/index.ts) + const directivesModules = combineDirective(directiveRawModules) + // 提取文件名(./modules/copy/index.ts => copy) + const reg = /(?<=modules\/).*(?=\/index\.ts)/ + + forIn(directivesModules, (value, key) => { + const dname = key.match(reg)?.[0] + + if (isValueType(dname, 'String')) { + app.directive(dname, value) + } else { + throw new Error( + 'directiveName is not string, please check your directive file name', + ) + } + }) +} diff --git a/src/directives/modules/copy/index.ts b/src/directives/modules/copy/index.ts new file mode 100644 index 00000000..0fb51bbc --- /dev/null +++ b/src/directives/modules/copy/index.ts @@ -0,0 +1,54 @@ +/** + * + * @author Ray + * + * @date 2023-06-24 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * directive name: copy + */ + +import ClipboardJS from 'clipboard' + +import type { Directive } from 'vue' +import type { CopyElement } from './type' + +let clipboard: ClipboardJS | null + +const copyDirective: Directive = { + mounted: (el, binding) => { + const value = binding.value + + clipboard = new ClipboardJS(el, { + text: () => String(value), + }) + + clipboard?.on('success', () => { + window.$message.success('复制成功') + }) + clipboard?.on('error', () => { + window.$message.error('复制失败') + }) + }, + updated: (el, binding) => { + /** 其实这块代码写的挺蠢的, 但是我目前不知道怎么去优化, 阿巴阿巴阿巴 */ + const value = binding.value + + clipboard = new ClipboardJS(el, { + text: () => String(value), + }) + }, + beforeUnmount: () => { + clipboard?.destroy() + + clipboard = null + }, +} + +export default copyDirective diff --git a/src/directives/modules/copy/type.ts b/src/directives/modules/copy/type.ts new file mode 100644 index 00000000..5b3f4f00 --- /dev/null +++ b/src/directives/modules/copy/type.ts @@ -0,0 +1,3 @@ +export interface CopyElement extends Element, UnknownObjectKey { + $value: string +} diff --git a/src/directives/modules/debounce/index.ts b/src/directives/modules/debounce/index.ts new file mode 100644 index 00000000..7101bf48 --- /dev/null +++ b/src/directives/modules/debounce/index.ts @@ -0,0 +1,49 @@ +/** + * + * @author Ray + * + * @date 2023-06-24 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * directive name: debounce + */ + +import { debounce } from 'lodash-es' +import { on, off } from '@use-utils/element' + +import type { Directive } from 'vue' +import type { DebounceBindingOptions } from './type' +import type { AnyFunc } from '@/types/modules/utils' + +let debounceFunction: AnyFunc | null + +const debounceDirective: Directive = { + beforeMount: (el, binding) => { + const { func, trigger = 'click', wait = 500, options } = binding.value + + if (typeof func !== 'function') { + throw new Error('debounce directive value must be a function') + } + + debounceFunction = debounce(func, wait, Object.assign({}, {}, options)) + + on(el, trigger, debounceFunction) + }, + beforeUnmount: (el, binding) => { + const { trigger = 'click' } = binding.value + + if (debounceFunction) { + off(el, trigger, debounceFunction) + } + + debounceFunction = null + }, +} + +export default debounceDirective diff --git a/src/directives/modules/debounce/type.ts b/src/directives/modules/debounce/type.ts new file mode 100644 index 00000000..25aa981d --- /dev/null +++ b/src/directives/modules/debounce/type.ts @@ -0,0 +1,9 @@ +import type { DebounceSettings } from 'lodash-es' +import type { AnyFunc } from '@/types/modules/utils' + +export interface DebounceBindingOptions { + func: AnyFunc + trigger: string + wait: number + options: DebounceSettings +} diff --git a/src/directives/modules/disabled/index.ts b/src/directives/modules/disabled/index.ts new file mode 100644 index 00000000..6b6a15b2 --- /dev/null +++ b/src/directives/modules/disabled/index.ts @@ -0,0 +1,43 @@ +/** + * + * @author Ray + * + * @date 2023-06-26 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * directive name: disabled + */ + +import { addClass, removeClass } from '@/utils/element' + +import type { Directive } from 'vue' + +const updateElementDisabledType = (el: HTMLElement, value: boolean) => { + if (el) { + const classes = 'ray-template__directive--disabled' + + value ? addClass(el, classes) : removeClass(el, classes) + el?.setAttribute('disabled', value ? 'disabled' : '') + } +} + +const disabledDirective: Directive = { + mounted: (el, binding) => { + const value = binding.value + + updateElementDisabledType(el, value) + }, + updated: (el, binding) => { + const value = binding.value + + updateElementDisabledType(el, value) + }, +} + +export default disabledDirective diff --git a/src/directives/modules/throttle/index.ts b/src/directives/modules/throttle/index.ts new file mode 100644 index 00000000..c6f632bb --- /dev/null +++ b/src/directives/modules/throttle/index.ts @@ -0,0 +1,49 @@ +/** + * + * @author Ray + * + * @date 2023-06-24 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * directive name: throttle + */ + +import { throttle } from 'lodash-es' +import { on, off } from '@use-utils/element' + +import type { Directive } from 'vue' +import type { ThrottleBindingOptions } from './type' +import type { AnyFunc } from '@/types/modules/utils' + +let throttleFunction: AnyFunc | null + +const throttleDirective: Directive = { + beforeMount: (el, binding) => { + const { func, trigger = 'click', wait = 500, options } = binding.value + + if (typeof func !== 'function') { + throw new Error('throttle directive value must be a function') + } + + throttleFunction = throttle(func, wait, Object.assign({}, {}, options)) + + on(el, trigger, throttleFunction) + }, + beforeUnmount: (el, binding) => { + const { trigger = 'click' } = binding.value + + if (throttleFunction) { + off(el, trigger, throttleFunction) + } + + throttleFunction = null + }, +} + +export default throttleDirective diff --git a/src/directives/modules/throttle/type.ts b/src/directives/modules/throttle/type.ts new file mode 100644 index 00000000..9d8c36c7 --- /dev/null +++ b/src/directives/modules/throttle/type.ts @@ -0,0 +1,9 @@ +import type { ThrottleSettings } from 'lodash-es' +import type { AnyFunc } from '@/types/modules/utils' + +export interface ThrottleBindingOptions { + func: AnyFunc + trigger: string + wait: number + options: ThrottleSettings +} diff --git a/src/directives/type.ts b/src/directives/type.ts new file mode 100644 index 00000000..bc47065c --- /dev/null +++ b/src/directives/type.ts @@ -0,0 +1,5 @@ +import type { Directive } from 'vue' + +export interface DirectiveModules extends Object { + default: Directive +} diff --git a/src/error/PageResult/index.scss b/src/error/PageResult/index.scss new file mode 100644 index 00000000..4f125e3a --- /dev/null +++ b/src/error/PageResult/index.scss @@ -0,0 +1,5 @@ +.error-page { + width: 100%; + height: 100vh; + @include flexCenter; +} diff --git a/src/error/PageResult/index.tsx b/src/error/PageResult/index.tsx new file mode 100644 index 00000000..abd96bde --- /dev/null +++ b/src/error/PageResult/index.tsx @@ -0,0 +1,50 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 错误页面 + * + * 基于 NResult 组件实现, 继承该组件所有 props 与 slots + * 可以当作一个组件使用, 也可以当作一个页面调用 + */ + +import './index.scss' + +import { NResult, NButton } from 'naive-ui' + +import { redirectRouterToDashboard } from '@/router/helper/routerCopilot' +import { resultProps } from 'naive-ui' + +const PageResult = defineComponent({ + name: 'PageResult', + props: { + ...resultProps, + }, + render() { + return ( +
+ + {{ + ...this.$slots, + footer: () => ( + + 返回首页 + + ), + }} + +
+ ) + }, +}) + +export default PageResult diff --git a/src/error/README.md b/src/error/README.md new file mode 100644 index 00000000..7f7a00c3 --- /dev/null +++ b/src/error/README.md @@ -0,0 +1,10 @@ +## 异常 + +### 说明 + +- 当前系统使用 Error404 作为系统的默认异常页重定向页面(可自行替换或者更改) +- 提供公共的入口组件,虽然没啥大用,强迫症患者典型 + +### 更改与替换 + +> 如果需要自己更改,或者补充更多的异常页面,可以在当前文件的 views 下补充(建议这么做哈)。也可以直接摒弃现有的东西,全部重新折腾一次,因为这个东西,一般不会太复杂。 diff --git a/src/error/index.ts b/src/error/index.ts new file mode 100644 index 00000000..1b74c9c1 --- /dev/null +++ b/src/error/index.ts @@ -0,0 +1,3 @@ +import PageResult from './PageResult/index' + +export default PageResult diff --git a/src/error/views/Error404/index.tsx b/src/error/views/Error404/index.tsx new file mode 100644 index 00000000..4866300a --- /dev/null +++ b/src/error/views/Error404/index.tsx @@ -0,0 +1,24 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import PageResult from '@/error/index' + +const ErrorPage404 = defineComponent({ + name: 'ErrorPage404', + setup() { + return {} + }, + render() { + return + }, +}) + +export default ErrorPage404 diff --git a/src/error/views/Error500/index.tsx b/src/error/views/Error500/index.tsx new file mode 100644 index 00000000..40983271 --- /dev/null +++ b/src/error/views/Error500/index.tsx @@ -0,0 +1,24 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import PageResult from '@/error/index' + +const ErrorPage500 = defineComponent({ + name: 'ErrorPage500', + setup() { + return {} + }, + render() { + return + }, +}) + +export default ErrorPage500 diff --git a/src/icons/A_README.md b/src/icons/A_README.md new file mode 100644 index 00000000..5ffe5148 --- /dev/null +++ b/src/icons/A_README.md @@ -0,0 +1,17 @@ +## 说明 + +该文件包属于全局 `svg icon`,配合 `RayIcon` 组件使用。 + +## TIP + +添加新的 `svg` 图标时,应该注意图标自带 `fill` 属性的管理。如果自带了 `fill` 属性的图标,则会导致使用组件 `color` 属性失效的问题。所以如果是需要动态使用 `css` 属性控制样式的图标,应该去掉其 `fill` 属性或者配置为 `fill = currentColor`。 + +```html + +``` + +## 使用方法 + +- 导入 `svg` 图标 +- 命名(`命名必须全局唯一,并且尽量避免使用特殊符号`) +- 导入 `RayIcon` 组件,配置 `name` 属性即可将 `svg` 作为图标使用 diff --git a/src/icons/adjustment.svg b/src/icons/adjustment.svg new file mode 100644 index 00000000..bdca828e --- /dev/null +++ b/src/icons/adjustment.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/icons/axios.svg b/src/icons/axios.svg new file mode 100644 index 00000000..7c12ac06 --- /dev/null +++ b/src/icons/axios.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/close.svg b/src/icons/close.svg new file mode 100644 index 00000000..bd6c6877 --- /dev/null +++ b/src/icons/close.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/icons/dark.svg b/src/icons/dark.svg new file mode 100644 index 00000000..9c69a797 --- /dev/null +++ b/src/icons/dark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/dashboard.svg b/src/icons/dashboard.svg new file mode 100644 index 00000000..f680d2a8 --- /dev/null +++ b/src/icons/dashboard.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/icons/doc.svg b/src/icons/doc.svg new file mode 100644 index 00000000..6b7be9ee --- /dev/null +++ b/src/icons/doc.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/icons/draggable.svg b/src/icons/draggable.svg new file mode 100644 index 00000000..d5ff298a --- /dev/null +++ b/src/icons/draggable.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/echart.svg b/src/icons/echart.svg new file mode 100644 index 00000000..253fc303 --- /dev/null +++ b/src/icons/echart.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/error.svg b/src/icons/error.svg new file mode 100644 index 00000000..ad9fb923 --- /dev/null +++ b/src/icons/error.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/icons/expanded.svg b/src/icons/expanded.svg new file mode 100644 index 00000000..09c0c573 --- /dev/null +++ b/src/icons/expanded.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/icons/export_excel.svg b/src/icons/export_excel.svg new file mode 100644 index 00000000..44937b41 --- /dev/null +++ b/src/icons/export_excel.svg @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/src/icons/fullscreen.svg b/src/icons/fullscreen.svg new file mode 100644 index 00000000..92f163c3 --- /dev/null +++ b/src/icons/fullscreen.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/github.svg b/src/icons/github.svg new file mode 100644 index 00000000..1c0235ed --- /dev/null +++ b/src/icons/github.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/google.svg b/src/icons/google.svg new file mode 100644 index 00000000..d422a58f --- /dev/null +++ b/src/icons/google.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/icons/language.svg b/src/icons/language.svg new file mode 100644 index 00000000..b399fdcb --- /dev/null +++ b/src/icons/language.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/icons/left_arrow.svg b/src/icons/left_arrow.svg new file mode 100644 index 00000000..356171b0 --- /dev/null +++ b/src/icons/left_arrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/light.svg b/src/icons/light.svg new file mode 100644 index 00000000..2df0d3e2 --- /dev/null +++ b/src/icons/light.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/icons/lock.svg b/src/icons/lock.svg new file mode 100644 index 00000000..12e8dcf5 --- /dev/null +++ b/src/icons/lock.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/icons/login_bg.svg b/src/icons/login_bg.svg new file mode 100644 index 00000000..12ed3eb1 --- /dev/null +++ b/src/icons/login_bg.svg @@ -0,0 +1,602 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/icons/more.svg b/src/icons/more.svg new file mode 100644 index 00000000..c18fd376 --- /dev/null +++ b/src/icons/more.svg @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/src/icons/office.svg b/src/icons/office.svg new file mode 100644 index 00000000..1bb9e2c5 --- /dev/null +++ b/src/icons/office.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/icons/other.svg b/src/icons/other.svg new file mode 100644 index 00000000..517ad080 --- /dev/null +++ b/src/icons/other.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/src/icons/print.svg b/src/icons/print.svg new file mode 100644 index 00000000..0501d0c5 --- /dev/null +++ b/src/icons/print.svg @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/src/icons/qrcode.svg b/src/icons/qrcode.svg new file mode 100644 index 00000000..8a844786 --- /dev/null +++ b/src/icons/qrcode.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/icons/ray.svg b/src/icons/ray.svg new file mode 100644 index 00000000..daaef75a --- /dev/null +++ b/src/icons/ray.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/icons/reload.svg b/src/icons/reload.svg new file mode 100644 index 00000000..ac935338 --- /dev/null +++ b/src/icons/reload.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/rely.svg b/src/icons/rely.svg new file mode 100644 index 00000000..3bb8d53d --- /dev/null +++ b/src/icons/rely.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/icons/resize_h.svg b/src/icons/resize_h.svg new file mode 100644 index 00000000..f78d5e09 --- /dev/null +++ b/src/icons/resize_h.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/right_arrow.svg b/src/icons/right_arrow.svg new file mode 100644 index 00000000..a1659e24 --- /dev/null +++ b/src/icons/right_arrow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/scroll_reveal.svg b/src/icons/scroll_reveal.svg new file mode 100644 index 00000000..4bf132eb --- /dev/null +++ b/src/icons/scroll_reveal.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/icons/search.svg b/src/icons/search.svg new file mode 100644 index 00000000..d1952363 --- /dev/null +++ b/src/icons/search.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/icons/setting.svg b/src/icons/setting.svg new file mode 100644 index 00000000..c3de195b --- /dev/null +++ b/src/icons/setting.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/icons/table.svg b/src/icons/table.svg new file mode 100644 index 00000000..81b761a0 --- /dev/null +++ b/src/icons/table.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/icons/twitter.svg b/src/icons/twitter.svg new file mode 100644 index 00000000..2d8eb3eb --- /dev/null +++ b/src/icons/twitter.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/layout/components/Menu/index.scss b/src/layout/components/Menu/index.scss new file mode 100644 index 00000000..b4d90627 --- /dev/null +++ b/src/layout/components/Menu/index.scss @@ -0,0 +1,43 @@ +/** + * + * @author Ray + * + * @date 2023-01-06 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +.ray-menu__logo { + height: 50px; + padding: 0 18px 0 24px; + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + gap: 8px 12px; + font-weight: 600; + overflow: hidden; + border-right: solid 1px var(--n-border-color); + + &.ray-menu__logo-url { + position: sticky; + top: 0; + cursor: pointer; + background-color: var(--n-color); + z-index: 20; + } + + & .ray-menu__logo-title { + opacity: 0; + display: none; + flex: 1; + @include overflowEllipsis; + + &.ray-menu__logo-title--open { + opacity: 1; + display: inline-block; + } + } +} diff --git a/src/layout/components/Menu/index.tsx b/src/layout/components/Menu/index.tsx new file mode 100644 index 00000000..027de23f --- /dev/null +++ b/src/layout/components/Menu/index.tsx @@ -0,0 +1,120 @@ +import './index.scss' + +import { NMenu, NLayoutSider, NEllipsis } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' + +import { useMenu } from '@/store' +import { APP_MENU_CONFIG } from '@/appConfig/appConfig' +import { useVueRouter } from '@/router/helper/useVueRouter' + +import type { MenuInst } from 'naive-ui' +import type { NaiveMenuOptions } from '@/types/modules/component' +import type { AppMenuOption } from '@/types/modules/app' + +const LayoutMenu = defineComponent({ + name: 'LayoutMenu', + setup() { + const menuRef = ref(null) + + const menuStore = useMenu() + const { router } = useVueRouter() + + const { changeMenuModelValue, collapsedMenu } = menuStore + const modelMenuKey = computed({ + get: () => { + nextTick().then(() => { + showMenuOption() + }) + + return menuStore.menuKey + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + set: () => {}, + }) + const modelMenuOptions = computed(() => menuStore.options) + const modelCollapsed = computed(() => menuStore.collapsed) + const { + layout: { sideBarLogo }, + } = __APP_CFG__ + + const handleSideBarLogoClick = () => { + if (sideBarLogo && sideBarLogo.url) { + sideBarLogo.jumpType === 'station' + ? router.push(sideBarLogo.url) + : window.open(sideBarLogo.url) + } + } + + const showMenuOption = () => { + const key = modelMenuKey.value as string + + nextTick().then(() => { + menuRef.value?.showOption?.(key) + }) + } + + return { + modelMenuKey, + changeMenuModelValue, + modelMenuOptions, + modelCollapsed, + collapsedMenu, + sideBarLogo, + handleSideBarLogoClick, + menuRef, + } + }, + render() { + return ( + + {this.sideBarLogo ? ( +
+ {this.sideBarLogo.icon ? ( + + ) : ( + '' + )} +

+ {this.sideBarLogo.title} +

+
+ ) : ( + '' + )} + { + this.changeMenuModelValue(key, op as unknown as AppMenuOption) + }} + accordion={APP_MENU_CONFIG.MENU_ACCORDION} + /> +
+ ) + }, +}) + +export default LayoutMenu diff --git a/src/layout/components/MenuTag/index.scss b/src/layout/components/MenuTag/index.scss new file mode 100644 index 00000000..d7238a22 --- /dev/null +++ b/src/layout/components/MenuTag/index.scss @@ -0,0 +1,44 @@ +$space: calc($layoutRouterViewContainer / 2); +$menuTagWrapperWidth: 76px; + +.menu-tag { + height: $layoutMenuHeight; + border-bottom: solid 1px var(--n-border-color); + display: flex; + align-items: center; + + & .menu-tag-sapce { + width: calc(100% - $space * 2); + padding: $space; + + & .menu-tag-wrapper { + width: calc(100% - $space * 2 - $menuTagWrapperWidth); + } + + & .ray-icon { + cursor: pointer; + } + + & .menu-tag__left-arrow { + transform: rotate(90deg); + } + + & .menu-tag__right-wrapper { + display: inline-flex; + align-items: center; + + & .menu-tag__right-arrow { + transform: rotate(270deg); + } + + & .menu-tag__right-setting { + width: 28px; + height: 20px; + } + } + } + + & .n-tag { + cursor: pointer; + } +} diff --git a/src/layout/components/MenuTag/index.tsx b/src/layout/components/MenuTag/index.tsx new file mode 100644 index 00000000..df87d860 --- /dev/null +++ b/src/layout/components/MenuTag/index.tsx @@ -0,0 +1,547 @@ +/** + * + * @author Ray + * + * @date 2022-12-08 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 操作说明: + * - 关闭全部: 关闭所有标签页, 并且重定向至根页面 rootRoute.path + * - 关闭右侧: 关闭右侧所有标签, 如果选中标签页与当前激活页不一致并且激活页在右侧, 则会重定向至当前选中标签页 + * - 关闭左侧: 关闭左侧所有标签, 如果选中标签页与当前激活页不一致并且激活页在左侧, 则会重定向至当前选中标签页 + * - 关闭其他: 关闭其他所有标签, 如果选中标签页与当前激活页不一致并且激活页在其中, 则会重定向至当前选中标签页 + * + * root path 标签不可被关闭, 所以不会显示关闭按钮 + * 页面刷新后, 仅会保留刷新前激活 key 的 tag 标签 + * + * 注入 MENU_TAG_DATA 属性, 用于动态更新 MenuTag 标签所在的位置 + */ + +import './index.scss' + +import { NScrollbar, NTag, NSpace, NLayoutHeader, NDropdown } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' + +import { useMenu, useSetting } from '@/store' +import { uuid } from '@/utils/hook' +import { hasClass } from '@/utils/element' +import { redirectRouterToDashboard } from '@/router/helper/routerCopilot' +import { ROOT_ROUTE } from '@/appConfig/appConfig' +import { queryElements } from '@use-utils/element' + +import type { MenuOption, ScrollbarInst } from 'naive-ui' +import type { MenuTagOptions, AppMenuOption } from '@/types/modules/app' + +const MenuTag = defineComponent({ + name: 'MenuTag', + setup(_, { expose }) { + const scrollRef = ref(null) + + const menuStore = useMenu() + const settingStore = useSetting() + + const { menuKey, menuTagOptions } = storeToRefs(menuStore) + const { + changeMenuModelValue, + spliceMenTagOptions, + emptyMenuTagOptions, + setMenuTagOptions, + } = menuStore + const { changeSwitcher } = settingStore + const { path } = ROOT_ROUTE + + const exclude = ['closeAll', 'closeRight', 'closeLeft', 'closeOther'] + let currentContentmenuIndex = -1 // 当前右键标签页索引位置 + const modelMenuTagOptions = computed(() => + menuTagOptions.value.map((curr, _idx, currentArray) => { + if (curr.key === menuKey.value && curr.key !== path) { + curr.closeable = true + } else { + curr.closeable = false + } + + if (curr.key === path) { + curr.closeable = false + } + + if (currentArray.length <= 1) { + curr.closeable = false + } + + return curr + }), + ) + const moreOptions = ref([ + { + label: '重新加载', + key: 'reloadCurrentPage', + icon: () => + h( + RayIcon, + { + size: 16, + name: 'reload', + }, + {}, + ), + }, + { + label: '关闭其他', + key: 'closeOther', + icon: () => + h( + RayIcon, + { + size: 16, + name: 'other', + }, + {}, + ), + }, + { + label: '关闭右侧', + key: 'closeRight', + icon: () => + h( + RayIcon, + { + size: 16, + name: 'right_arrow', + }, + {}, + ), + }, + { + label: '关闭左侧', + key: 'closeLeft', + icon: () => + h( + RayIcon, + { + size: 16, + name: 'left_arrow', + }, + {}, + ), + }, + { + type: 'divider', + key: 'd1', + }, + { + label: '全部关闭', + key: 'closeAll', + icon: () => + h( + RayIcon, + { + size: 16, + name: 'close', + }, + {}, + ), + disabled: false, + }, + ]) + const scrollBarUUID = uuid(16) + const actionMap = { + reloadCurrentPage: () => { + changeSwitcher(false, 'reloadRouteSwitch') + + setTimeout(() => changeSwitcher(true, 'reloadRouteSwitch')) + }, + closeAll: () => { + /** + * + * 关闭全部标签页, 然后重定向至首页(dashboard) + * 如果做了相关更改, 则需要手动更新 + */ + if (moreOptions.value.length > 1) { + emptyMenuTagOptions() + redirectRouterToDashboard(true) + } + }, + closeRight: () => { + /** + * + * 关闭右侧标签 + * + * 如果当前选择标签与 menuKey 不匹配, 则会关闭当前标签右侧所有变迁并且跳转至该页面 + */ + const length = moreOptions.value.length + const routeItem = modelMenuTagOptions.value[currentContentmenuIndex] + + spliceMenTagOptions(currentContentmenuIndex + 1, length - 1) + + if (menuKey.value !== routeItem.key) { + changeMenuModelValue(routeItem.key, routeItem) + } + }, + closeLeft: () => { + spliceMenTagOptions(0, currentContentmenuIndex) + }, + closeOther: () => { + /** + * + * 关闭其他标签 + * + * 如果关闭标签与当前 menuKey 不匹配, 则会关闭当前选择标签页以外的所有标签页并且跳转至该页面 + */ + const routeItem = modelMenuTagOptions.value[currentContentmenuIndex] + + if (menuKey.value !== routeItem.key) { + emptyMenuTagOptions() + changeMenuModelValue(routeItem.key, routeItem) + } else { + setMenuTagOptions(routeItem, false) + } + }, + } + /** 右键菜单 */ + const actionState = reactive({ + x: 0, + y: 0, + actionDropdownShow: false, + }) + const MENU_TAG_DATA = 'menu_tag_data' + + /** + * + * @param idx 索引 + * + * @remark 关闭 `tag` 菜单, 如果仅有一个则不能关闭 + */ + const closeCurrentMenuTag = (idx: number) => { + spliceMenTagOptions(idx) + + if (menuKey.value !== path) { + const options = modelMenuTagOptions.value + const length = options.length + + const tag = options[length - 1] + + changeMenuModelValue(tag.key as string, tag) + } + } + + const setMoreOptionsDisabled = ( + key: string | number, + disabled: boolean, + ) => { + moreOptions.value.forEach((curr) => { + if (curr.key === key) { + curr.disabled = disabled + + return + } + }) + } + + /** + * + * @param item 当前菜单值 + */ + const handleTagClick = (item: AppMenuOption) => { + changeMenuModelValue(item.key as string, item) + } + + const getScrollElement = () => { + const scroll = document.getElementById(scrollBarUUID) // 获取滚动条容器 + + if (scroll) { + const scrollContentElement = Array.from( + scroll.childNodes, + ) as HTMLElement[] + const findElement = scrollContentElement.find((el) => + hasClass(el, 'n-scrollbar-container'), + ) + + return findElement + } + + return undefined + } + + const handleScrollX = (type: 'left' | 'right') => { + const el = getScrollElement() + + if (el) { + /** + * + * 找到实际横向滚动元素(class: n-scrollbar-container) + * 获取 scrollLeft 属性后, 用于左右滚动边界值进行处理 + */ + const scrollX = el!.scrollLeft || 0 + const rolling = + type === 'left' ? Math.max(0, scrollX - 200) : scrollX + 200 + + scrollRef.value?.scrollTo({ + left: rolling, + behavior: 'smooth', + }) + } + } + + /** 更多操作操作栏 */ + const actionDropdownSelect = (key: string | number) => { + actionState.actionDropdownShow = false + + actionMap[key]?.() + } + + /** + * + * 右键点击标签页 + * + * 缓存当前点击标签页索引值(用于关闭左或者右侧标签页操作) + */ + const handleContextMenu = (idx: number, e: MouseEvent) => { + e.preventDefault() + + actionState.actionDropdownShow = false + currentContentmenuIndex = idx + + nextTick().then(() => { + actionState.actionDropdownShow = true + actionState.x = e.clientX + actionState.y = e.clientY + }) + } + + const setDisabledAccordionToIndex = () => { + const length = modelMenuTagOptions.value.length - 1 + + if (currentContentmenuIndex === length) { + setMoreOptionsDisabled('closeRight', true) + } else if (currentContentmenuIndex < length) { + setMoreOptionsDisabled('closeRight', false) + } + + if (currentContentmenuIndex === 0) { + setMoreOptionsDisabled('closeLeft', true) + } else if (currentContentmenuIndex > 0) { + setMoreOptionsDisabled('closeLeft', false) + } + } + + /** + * + * 如果通过更多按钮触发关闭事件, 则根据当前标签所在索引值为 currentContentmenuIndex + * + * 并且动态设置是否可操作状态 + */ + const setCurrentContentmenuIndex = () => { + const index = modelMenuTagOptions.value.findIndex( + (curr) => curr.key === menuKey.value, + ) + + currentContentmenuIndex = index + + setDisabledAccordionToIndex() + } + + /** 仅有 modelMenuTagOptions 长度大于 1 并且非 root path 时, 才激活关闭按钮 */ + const menuTagMouseenter = (option: MenuTagOptions) => { + if (modelMenuTagOptions.value.length > 1 && option.key !== path) { + option.closeable = true + } + } + + /** 移出 MenuTag 时, 判断是否为当前已激活 key */ + const menuTagMouseleave = (option: MenuTagOptions) => { + if (option.key !== menuKey.value) { + option.closeable = false + } + } + + /** + * + * 每当新的页面打开后, 将滚动条横向滚到至底部 + * 使用 nextTick 避免元素未渲染挂载至页面 + */ + const updateScrollBarPosition = () => { + const el = getScrollElement() + + if (el) { + nextTick().then(() => { + scrollRef.value?.scrollTo({ + left: 99999, + behavior: 'smooth', + }) + }) + } + } + + /** 动态更新 menu tag 所在位置 */ + const positionMenuTag = () => { + nextTick().then(() => { + const tags = queryElements( + `attr:${MENU_TAG_DATA}="${menuKey.value}"`, + ) + + if (tags?.length) { + const [menuTag] = tags + + menuTag.scrollIntoView?.() + } + }) + } + + /** 如果有且只有一个标签页时, 禁止全部关闭操作 */ + watch( + () => modelMenuTagOptions.value, + (newData, oldData) => { + moreOptions.value.forEach((curr) => { + if (exclude.includes(curr.key)) { + newData.length > 1 + ? (curr.disabled = false) + : (curr.disabled = true) + } + }) + + if (oldData?.length) { + if (newData.length > oldData?.length) { + updateScrollBarPosition() + } else if (newData.length === oldData?.length) { + positionMenuTag() + } + } + }, + { + immediate: true, + }, + ) + + /** 动态设置关闭按钮是否可操作 */ + watch( + () => actionState.actionDropdownShow, + () => { + setDisabledAccordionToIndex() + }, + ) + + expose({}) + + return { + modelMenuTagOptions, + changeMenuModelValue, + closeCurrentMenuTag, + menuKey, + handleTagClick, + moreOptions, + handleScrollX, + scrollRef, + scrollBarUUID, + actionDropdownSelect, + rootPath: path, + actionState, + handleContextMenu, + setCurrentContentmenuIndex, + menuTagMouseenter, + menuTagMouseleave, + MENU_TAG_DATA, + } + }, + render() { + return ( + + + + ) + }, +}) + +export default MenuTag diff --git a/src/layout/components/SiderBar/components/Breadcrumb/index.tsx b/src/layout/components/SiderBar/components/Breadcrumb/index.tsx new file mode 100644 index 00000000..00ebbe30 --- /dev/null +++ b/src/layout/components/SiderBar/components/Breadcrumb/index.tsx @@ -0,0 +1,96 @@ +/** + * + * @author Ray + * + * @date 2023-03-03 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 顶部面包屑 + * + * 如果下拉菜单条目小于一条, 则不会触发下拉菜单 + * + * 添加 标签, 避免 Runtime directive used on component... 警告 + */ + +import { NDropdown, NBreadcrumb, NBreadcrumbItem } from 'naive-ui' + +import { useMenu } from '@/store' + +import type { DropdownOption, MenuOption } from 'naive-ui' +import type { + AppMenuOption, + MenuTagOptions, + AppMenuKey, +} from '@/types/modules/app' + +const Breadcrumb = defineComponent({ + name: 'RBreadcrumb', + setup() { + const menuStore = useMenu() + + const { changeMenuModelValue } = menuStore + const { breadcrumbOptions } = storeToRefs(menuStore) + const modelBreadcrumbOptions = computed(() => breadcrumbOptions.value) + + const handleDropdownSelect = ( + key: string | number, + option: DropdownOption, + ) => { + changeMenuModelValue(key, option as unknown as AppMenuOption) + } + + const handleBreadcrumbItemClick = (option: AppMenuOption) => { + if (!option.children?.length) { + const { meta = {} } = option + + if (!meta.sameLevel) { + changeMenuModelValue(option.key, option) + } + } + } + + return { + modelBreadcrumbOptions, + handleDropdownSelect, + handleBreadcrumbItemClick, + } + }, + render() { + return ( + + {this.modelBreadcrumbOptions.map((curr) => ( + + 1 ? curr.children : [] + } + onSelect={this.handleDropdownSelect.bind(this)} + > + {{ + default: () => ( + + {curr.label && typeof curr.label === 'function' + ? curr.label() + : curr.breadcrumbLabel} + + ), + }} + + + ))} + + ) + }, +}) + +export default Breadcrumb diff --git a/src/layout/components/SiderBar/components/GlobalSeach/index.scss b/src/layout/components/SiderBar/components/GlobalSeach/index.scss new file mode 100644 index 00000000..95592308 --- /dev/null +++ b/src/layout/components/SiderBar/components/GlobalSeach/index.scss @@ -0,0 +1,102 @@ +$globalSearchWidth: 650px; + +.global-seach { + position: fixed; + width: $globalSearchWidth; + left: 50%; + margin-left: calc(0px - $globalSearchWidth / 2); + top: 60px; + + & .global-seach__wrapper { + box-sizing: border-box; + + & .global-seach__card { + width: $globalSearchWidth; + border-radius: 6px; + padding: 12px; + + & .ray-icon { + color: var(--ray-theme-primary-color); + } + + & .global-seach__card-header { + margin-bottom: 12px; + } + + & .global-seach__card-content { + height: auto; + max-height: calc(100% - 98px); + + & .content-item { + padding: 12px; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.3s var(--r-bezier); + + & .content-item-icon { + @include flexCenter; + } + } + } + + & .global-seach__card-footer { + width: 100%; + + & .card-footer__tip-wrapper { + display: flex; + align-items: center; + margin-top: 24px; + + & .tip-wrapper-item { + display: flex; + align-items: center; + + & .item-icon { + display: flex; + align-items: center; + margin-right: 4px; + + & span { + color: var(--ray-theme-primary-color); + } + } + } + } + } + } + } +} + +.global-seach--dark { + @include useAppTheme("dark") { + & .global-seach__card { + background-color: #242424; + + & .global-seach__card-content .content-item { + background-color: #2f2f2f; + + &.content-item--active, + &:hover { + background-color: var(--ray-theme-primary-fade-color); + } + } + } + } +} + +.global-seach--light { + @include useAppTheme("light") { + & .global-seach__card { + background-color: #f9f9f9; + + & .global-seach__card-content .content-item { + background-color: #ffffff; + + &.content-item--active, + &:hover { + background-color: var(--ray-theme-primary-fade-color); + } + } + } + } +} diff --git a/src/layout/components/SiderBar/components/GlobalSeach/index.tsx b/src/layout/components/SiderBar/components/GlobalSeach/index.tsx new file mode 100644 index 00000000..a0043692 --- /dev/null +++ b/src/layout/components/SiderBar/components/GlobalSeach/index.tsx @@ -0,0 +1,338 @@ +/** + * + * @author Ray + * + * @date 2023-04-16 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +import { NInput, NModal, NResult, NScrollbar, NSpace } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' + +import { on, off, queryElements, addClass, removeClass } from '@/utils/element' +import { debounce } from 'lodash-es' +import { useMenu } from '@/store' +import { validMenuItemShow } from '@/router/helper/routerCopilot' + +import type { AppRouteMeta } from '@/router/type' +import type { AppMenuOption } from '@/types/modules/app' + +const GlobalSeach = defineComponent({ + name: 'GlobalSeach', + props: { + show: { + type: Boolean, + default: false, + }, + }, + emits: ['update:show'], + setup(props, { emit }) { + const menuStore = useMenu() + + const { changeMenuModelValue } = menuStore + const modelShow = computed({ + get: () => props.show, + set: (val) => { + emit('update:show', val) + + if (!val) { + resetSearchSomeValue() + } + }, + }) + const modelMenuOptions = computed(() => menuStore.options) + const state = reactive({ + searchValue: null, + searchOptions: [] as AppMenuOption[], + }) + const tiptextOptions = [ + { + icon: 'cmd / ctrl + k', + label: '唤起', + plain: true, + }, + { + icon: '↑ ↓', + label: '切换', + plain: true, + }, + { + icon: 'esc', + label: '关闭', + plain: true, + }, + ] + /** 初始化索引 */ + let searchElementIndex = 0 + /** 缓存索引 */ + let preSearchElementIndex = searchElementIndex + + /** 初始化一些值 */ + const resetSearchSomeValue = () => { + state.searchOptions = [] + state.searchValue = null + searchElementIndex = 0 + preSearchElementIndex = searchElementIndex + } + + /** 按下 ctrl + k 或者 command + k 激活搜索栏 */ + const registerArouseKeyboard = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault() + e.stopPropagation() + resetSearchSomeValue() + + modelShow.value = true + } + } + + /** 根据输入值模糊检索菜单 */ + const handleSearchMenuOptions = (value: string) => { + const arr: AppMenuOption[] = [] + + const filterArr = (options: AppMenuOption[]) => { + options.forEach((curr) => { + if (curr.children?.length && validMenuItemShow(curr)) { + filterArr(curr.children) + } + + /** 处理菜单名与输入值, 不区分大小写 */ + const _breadcrumbLabel = curr.breadcrumbLabel?.toLocaleLowerCase() + const _value = String(value).toLocaleLowerCase() + + if ( + _breadcrumbLabel?.includes(_value) && + validMenuItemShow(curr) && + !curr.children?.length + ) { + arr.push(curr) + } + }) + } + + if (value) { + filterArr(modelMenuOptions.value) + + state.searchOptions = arr + } else { + state.searchOptions = [] + } + + nextTick().then(() => { + autoFouceSearchItem() + }) + } + + const handleSearchItemClick = (option: AppMenuOption) => { + if (option) { + const { meta } = option + + /** 如果配置站外跳转则不会关闭搜索框 */ + if (meta.windowOpen) { + window.open(meta.windowOpen) + } else { + modelShow.value = false + + changeMenuModelValue(option.key, option) + } + } + } + + /** 自动聚焦检索项 */ + const autoFouceSearchItem = () => { + const currentOption = state.searchOptions[searchElementIndex] + const preOption = state.searchOptions[preSearchElementIndex] + + if (currentOption) { + nextTick().then(() => { + const searchElementOptions = queryElements( + `attr:data_path="${currentOption.path}"`, + ) + const preSearchElementOptions = preOption + ? queryElements(`attr:data_path="${preOption?.path}"`) + : null + + if (preSearchElementOptions?.length) { + const [el] = preSearchElementOptions + + removeClass(el, 'content-item--active') + } + + if (searchElementOptions?.length) { + const [el] = searchElementOptions + + addClass(el, 'content-item--active') + } + }) + } + } + + /** 渲染搜索菜单前缀图标, 如果没有则用 icon table 代替 */ + const RenderPreIcon = (meta: AppRouteMeta) => { + const { icon } = meta + + if (typeof icon === 'string') { + return + } else if (typeof icon === 'function') { + return () => icon + } else { + return + } + } + + /** 注册按键: 上、下、回车 */ + const registerChangeSearchElementIndex = (e: KeyboardEvent) => { + const keyCode = e.key + + if (keyCode === 'ArrowUp' || keyCode === 'ArrowDown') { + e.preventDefault() + e.stopPropagation() + } + + preSearchElementIndex = searchElementIndex <= 0 ? 0 : searchElementIndex + + /** 更新索引 */ + const updateIndex = (type: 'up' | 'down') => { + if (type === 'up') { + searchElementIndex = + searchElementIndex - 1 < 0 ? 0 : searchElementIndex - 1 + } else if (type === 'down') { + searchElementIndex = + searchElementIndex + 1 >= state.searchOptions.length + ? state.searchOptions.length - 1 + : searchElementIndex + 1 + } + } + + switch (keyCode) { + case 'ArrowUp': + updateIndex('up') + + break + case 'ArrowDown': + updateIndex('down') + + break + case 'Enter': + // eslint-disable-next-line no-case-declarations + const option = state.searchOptions[searchElementIndex] + + if (option) { + handleSearchItemClick(option) + } + + break + + default: + break + } + + autoFouceSearchItem() + } + + onMounted(() => { + on(window, 'keydown', (e: Event) => { + registerArouseKeyboard(e as KeyboardEvent) + registerChangeSearchElementIndex(e as KeyboardEvent) + }) + }) + + onBeforeUnmount(() => { + off(window, 'keydown', (e: Event) => { + registerArouseKeyboard(e as KeyboardEvent) + registerChangeSearchElementIndex(e as KeyboardEvent) + }) + }) + + return { + ...toRefs(state), + modelShow, + tiptextOptions, + handleSearchMenuOptions: debounce(handleSearchMenuOptions, 300), + handleSearchItemClick, + RenderPreIcon, + } + }, + render() { + return ( + +
+
+
+
+ + {{ + prefix: () => , + }} + +
+ + {this.searchOptions.length ? ( + + {this.searchOptions.map((curr) => ( + +
+ {this.RenderPreIcon(curr.meta)} +
+
+ {curr.breadcrumbLabel} +
+
+ ))} +
+ ) : ( + + {{ + icon: () => '', + }} + + )} +
+ +
+
+
+
+ ) + }, +}) + +export default GlobalSeach diff --git a/src/layout/components/SiderBar/components/SettingDrawer/components/ThemeSwitch/index.tsx b/src/layout/components/SiderBar/components/SettingDrawer/components/ThemeSwitch/index.tsx new file mode 100644 index 00000000..07b0f8ac --- /dev/null +++ b/src/layout/components/SiderBar/components/SettingDrawer/components/ThemeSwitch/index.tsx @@ -0,0 +1,91 @@ +/** + * + * @author Ray + * + * @date 2023-04-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { NSpace, NSwitch, NTooltip } from 'naive-ui' +import RayIcon from '@/components/RayIcon' + +import { useSetting } from '@/store' +import { useI18n } from '@/locales/useI18n' + +const ThemeSwitch = defineComponent({ + name: 'ThemeSwitch', + setup() { + const { t } = useI18n() + const settingStore = useSetting() + const { changeSwitcher } = settingStore + const { themeValue } = storeToRefs(settingStore) + + const handleRailStyle = ({ checked }: { checked: boolean }) => { + return checked + ? { + backgroundColor: '#000000', + } + : { + color: '#000000', + } + } + + return { + t, + changeSwitcher, + themeValue, + handleRailStyle, + } + }, + render() { + const { t } = this + + return ( + + + {{ + trigger: () => ( + + this.changeSwitcher(bool, 'themeValue') + } + > + {{ + 'checked-icon': () => + h( + RayIcon, + { + name: 'dark', + }, + {}, + ), + 'unchecked-icon': () => + h( + RayIcon, + { + name: 'light', + }, + {}, + ), + checked: () => '亮', + unchecked: () => '暗', + }} + + ), + default: () => + this.themeValue + ? t('headerSettingOptions.ThemeOptions.Dark') + : t('headerSettingOptions.ThemeOptions.Light'), + }} + + + ) + }, +}) + +export default ThemeSwitch diff --git a/src/layout/components/SiderBar/components/SettingDrawer/index.scss b/src/layout/components/SiderBar/components/SettingDrawer/index.scss new file mode 100644 index 00000000..760db0c1 --- /dev/null +++ b/src/layout/components/SiderBar/components/SettingDrawer/index.scss @@ -0,0 +1,8 @@ +.setting-drawer__space { + width: 100%; + + & .n-descriptions-table-content { + display: flex !important; + justify-content: space-between; + } +} diff --git a/src/layout/components/SiderBar/components/SettingDrawer/index.tsx b/src/layout/components/SiderBar/components/SettingDrawer/index.tsx new file mode 100644 index 00000000..ebed3dbf --- /dev/null +++ b/src/layout/components/SiderBar/components/SettingDrawer/index.tsx @@ -0,0 +1,129 @@ +import './index.scss' +import { + NDrawer, + NDrawerContent, + NDivider, + NSpace, + NSwitch, + NColorPicker, + NDescriptions, + NDescriptionsItem, +} from 'naive-ui' +import ThemeSwitch from '@/layout/components/SiderBar/components/SettingDrawer/components/ThemeSwitch/index' + +import { APP_THEME } from '@/appConfig/designConfig' +import { useSetting } from '@/store' +import { useI18n } from '@/locales/useI18n' + +import type { PropType } from 'vue' +import type { Placement } from '@/types/modules/component' + +const SettingDrawer = defineComponent({ + name: 'SettingDrawer', + props: { + show: { + type: Boolean, + default: false, + }, + placement: { + type: String as PropType, + default: 'right', + }, + width: { + type: Number, + default: 280, + }, + }, + emits: ['update:show'], + setup(props, { emit }) { + const { t } = useI18n() + const settingStore = useSetting() + + const { changePrimaryColor, changeSwitcher } = settingStore + const { + themeValue, + primaryColorOverride, + menuTagSwitch, + breadcrumbSwitch, + invertSwitch, + } = storeToRefs(settingStore) + + const modelShow = computed({ + get: () => props.show, + set: (bool) => { + emit('update:show', bool) + }, + }) + + return { + modelShow, + t, + changePrimaryColor, + themeValue, + primaryColorOverride, + menuTagSwitch, + changeSwitcher, + breadcrumbSwitch, + invertSwitch, + } + }, + render() { + const { t } = this + + return ( + + + + + {t('headerSettingOptions.ThemeOptions.Title')} + + + + {t('headerSettingOptions.ThemeOptions.PrimaryColorConfig')} + + + + {t('headerSettingOptions.InterfaceDisplay')} + + + + + this.changeSwitcher(bool, 'menuTagSwitch') + } + /> + + + + this.changeSwitcher(bool, 'breadcrumbSwitch') + } + /> + + + + this.changeSwitcher(bool, 'invertSwitch') + } + /> + + + + + + ) + }, +}) + +export default SettingDrawer diff --git a/src/layout/components/SiderBar/components/TooltipIcon/index.scss b/src/layout/components/SiderBar/components/TooltipIcon/index.scss new file mode 100644 index 00000000..571763cf --- /dev/null +++ b/src/layout/components/SiderBar/components/TooltipIcon/index.scss @@ -0,0 +1,5 @@ +.tooltip-text__icon { + cursor: pointer; + outline: none; + border: none; +} diff --git a/src/layout/components/SiderBar/components/TooltipIcon/index.tsx b/src/layout/components/SiderBar/components/TooltipIcon/index.tsx new file mode 100644 index 00000000..ddb0a507 --- /dev/null +++ b/src/layout/components/SiderBar/components/TooltipIcon/index.tsx @@ -0,0 +1,64 @@ +/** + * + * @author Ray + * + * @date 2022-12-29 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +import { NTooltip } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' + +import { tooltipProps } from 'naive-ui' + +const TooltipIcon = defineComponent({ + name: 'TooltipIcon', + props: { + ...tooltipProps, + iconName: { + type: String, + required: true, + }, + customClassName: { + type: String, + default: '', + }, + tooltipText: { + type: String, + default: '', + }, + }, + emits: ['click'], + setup(_, { emit }) { + const handleClick = (e?: MouseEvent) => { + emit('click', e) + } + return { + handleClick, + } + }, + render() { + return ( + + {{ + trigger: () => ( + + ), + default: () => this.tooltipText, + }} + + ) + }, +}) + +export default TooltipIcon diff --git a/src/layout/components/SiderBar/hook.ts b/src/layout/components/SiderBar/hook.ts new file mode 100644 index 00000000..de358624 --- /dev/null +++ b/src/layout/components/SiderBar/hook.ts @@ -0,0 +1,49 @@ +import { useSetting, useSignin } from '@/store' + +export const useAvatarOptions = () => [ + { + key: 'person', + label: '个人信息', + }, + { + key: 'lockScreen', + label: '锁定屏幕', + }, + { + type: 'divider', + key: 'd1', + }, + { + key: 'logout', + label: '退出登陆', + }, +] + +const avatarDropdownActionMap = { + logout: () => { + const signinStore = useSignin() + const { logout } = signinStore + + window.$dialog.warning({ + title: '提示', + content: '您确定要退出登录吗', + positiveText: '确定', + negativeText: '不确定', + onPositiveClick: () => { + logout() + }, + }) + }, + lockScreen: () => { + const settingStore = useSetting() + const { changeSwitcher } = settingStore + + changeSwitcher(true, 'lockScreenSwitch') + }, +} + +export const avatarDropdownClick = (key: string | number) => { + const action = avatarDropdownActionMap[key] + + action ? action() : window.$message.info('这个人很懒, 没做这个功能~') +} diff --git a/src/layout/components/SiderBar/index.scss b/src/layout/components/SiderBar/index.scss new file mode 100644 index 00000000..83c39953 --- /dev/null +++ b/src/layout/components/SiderBar/index.scss @@ -0,0 +1,16 @@ +.layout-header { + height: $layoutHeaderHeight; + padding: 0 $layoutRouterViewContainer; + display: flex; + align-items: center; + + > .layout-header__method { + width: 100%; + + & .layout-header__method--icon { + cursor: pointer; + outline: none; + border: none; + } + } +} diff --git a/src/layout/components/SiderBar/index.tsx b/src/layout/components/SiderBar/index.tsx new file mode 100644 index 00000000..60804127 --- /dev/null +++ b/src/layout/components/SiderBar/index.tsx @@ -0,0 +1,212 @@ +/** + * + * @author Ray + * + * @date 2023-01-04 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 本来想通过写数据配置化的方式实现顶部的功能小按钮, 结果事实发现... + * + * 但是我又不想改, 就这样吧 + */ + +import './index.scss' + +import { NLayoutHeader, NSpace, NTooltip, NDropdown } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' +import TootipIcon from '@/layout/components/SiderBar/components/TooltipIcon/index' +import SettingDrawer from './components/SettingDrawer/index' +import Breadcrumb from './components/Breadcrumb/index' +import GlobalSeach from './components/GlobalSeach/index' +import AppAvatar from '@/components/AppComponents/AppAvatar/index' + +import { useSetting } from '@/store' +import { LOCAL_OPTIONS } from '@/appConfig/localConfig' +import { useAvatarOptions, avatarDropdownClick } from './hook' +import screenfull from 'screenfull' +import { useI18n } from '@/locales/useI18n' + +import type { IconEventMapOptions, IconEventMap } from './type' +import type { SigninCallback } from '@/store/modules/signin/type' + +const SiderBar = defineComponent({ + name: 'SiderBar', + setup() { + const settingStore = useSetting() + + const { t } = useI18n() + const { updateLocale, changeSwitcher } = settingStore + + const { drawerPlacement, breadcrumbSwitch } = storeToRefs(settingStore) + const showSettings = ref(false) + const spaceItemStyle = { + display: 'flex', + } + const globalSearchShown = ref(false) + + /** + * + * 顶部左边操作栏 + */ + const leftIconOptions = computed(() => [ + { + name: 'reload', + size: 18, + tooltip: t('headerTooltip.Reload'), + }, + ]) + /** + * + * 顶部右边提示框操作栏 + */ + const rightTooltipIconOptions = computed(() => [ + { + name: 'search', + size: 18, + tooltip: t('headerTooltip.Search'), + eventKey: 'search', + }, + { + name: 'fullscreen', + size: 18, + tooltip: t('headerTooltip.FullScreen'), + eventKey: 'screen', + }, + { + name: 'github', + size: 18, + tooltip: t('headerTooltip.Github'), + eventKey: 'github', + }, + { + name: 'setting', + size: 18, + tooltip: t('headerTooltip.Setting'), + eventKey: 'setting', + }, + ]) + const iconEventMap: IconEventMapOptions = { + reload: () => { + changeSwitcher(false, 'reloadRouteSwitch') + + setTimeout(() => changeSwitcher(true, 'reloadRouteSwitch')) + }, + setting: () => { + showSettings.value = true + }, + github: () => { + window.open('https://github.com/XiaoDaiGua-Ray/ray-template') + }, + fullscreen: () => { + if (screenfull.isEnabled) { + screenfull.toggle() + } else { + window.$message.warning('您的浏览器不支持全屏~') + } + }, + search: () => { + globalSearchShown.value = true + }, + lock: () => { + changeSwitcher(true, 'lockScreenSwitch') + }, + } + + const handleIconClick = (key: IconEventMap) => { + iconEventMap[key]?.() + } + + return { + leftIconOptions, + rightTooltipIconOptions, + t, + handleIconClick, + showSettings, + updateLocale, + spaceItemStyle, + drawerPlacement, + breadcrumbSwitch, + globalSearchShown, + } + }, + render() { + return ( + + + + + {this.leftIconOptions.map((curr) => ( + + {{ + trigger: () => ( + + ), + default: () => curr.tooltip, + }} + + ))} + {this.breadcrumbSwitch ? : ''} + + + {this.rightTooltipIconOptions.map((curr) => ( + + ))} + + this.updateLocale(String(key)) + } + trigger="click" + > + + + + + + + + + + ) + }, +}) + +export default SiderBar diff --git a/src/layout/components/SiderBar/type.ts b/src/layout/components/SiderBar/type.ts new file mode 100644 index 00000000..4d690219 --- /dev/null +++ b/src/layout/components/SiderBar/type.ts @@ -0,0 +1,22 @@ +import type { DropdownOption } from 'naive-ui' + +export interface IconEventMapOptions { + [propName: string]: (...args: unknown[]) => unknown +} + +export type IconEventMap = keyof IconEventMapOptions + +export interface IconDropdownOptions extends UnknownObjectKey { + event?: string + switch: boolean + options: DropdownOption[] + eventKey?: string +} + +export interface IconOptions { + name: string + size?: number + tooltip?: string + eventKey?: string + dropdown?: IconDropdownOptions +} diff --git a/src/layout/default/ContentWrapper/index.scss b/src/layout/default/ContentWrapper/index.scss new file mode 100644 index 00000000..a2c09a37 --- /dev/null +++ b/src/layout/default/ContentWrapper/index.scss @@ -0,0 +1,3 @@ +.content-wrapper { + box-sizing: border-box; +} diff --git a/src/layout/default/ContentWrapper/index.tsx b/src/layout/default/ContentWrapper/index.tsx new file mode 100644 index 00000000..2788d8bc --- /dev/null +++ b/src/layout/default/ContentWrapper/index.tsx @@ -0,0 +1,38 @@ +/** + * + * @author Ray + * + * @date 2023-04-21 + * + * @workspace ray-template-mine + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +import RayTransitionComponent from '@/components/RayTransitionComponent/TransitionComponent.vue' + +import { useSetting } from '@/store' + +const ContentWrapper = defineComponent({ + name: 'ContentWrapper', + setup() { + const settingStore = useSetting() + + const { reloadRouteSwitch } = storeToRefs(settingStore) + + return { + reloadRouteSwitch, + } + }, + render() { + return this.reloadRouteSwitch ? ( + + ) : ( + <> + ) + }, +}) + +export default ContentWrapper diff --git a/src/layout/default/FooterWrapper/index.scss b/src/layout/default/FooterWrapper/index.scss new file mode 100644 index 00000000..bd7d04cc --- /dev/null +++ b/src/layout/default/FooterWrapper/index.scss @@ -0,0 +1,4 @@ +.layout-footer-wrapper { + padding: 24px; + text-align: center; +} diff --git a/src/layout/default/FooterWrapper/index.tsx b/src/layout/default/FooterWrapper/index.tsx new file mode 100644 index 00000000..96eef486 --- /dev/null +++ b/src/layout/default/FooterWrapper/index.tsx @@ -0,0 +1,34 @@ +/** + * + * @author Ray + * + * @date 2023-04-21 + * + * @workspace ray-template-mine + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +const FooterWrapper = defineComponent({ + name: 'FooterWrapper', + setup() { + const { + layout: { copyright }, + } = __APP_CFG__ + + return { + copyright, + } + }, + render() { + return this.copyright ? ( + + ) : ( + '' + ) + }, +}) + +export default FooterWrapper diff --git a/src/layout/index.scss b/src/layout/index.scss new file mode 100644 index 00000000..c8ffebc4 --- /dev/null +++ b/src/layout/index.scss @@ -0,0 +1,24 @@ +.layout { + box-sizing: border-box; + + > .layout-full { + height: 100%; + } + + & .layout__view-container__layout .n-layout-scroll-container { + overflow: hidden; + } + + & .layout-content__router-view { + height: var(--layout-content-height); + padding: calc($layoutRouterViewContainer / 2); + + & .n-scrollbar-container { + height: 100%; + + & .n-scrollbar-content { + height: 100%; + } + } + } +} diff --git a/src/layout/index.tsx b/src/layout/index.tsx new file mode 100644 index 00000000..38711671 --- /dev/null +++ b/src/layout/index.tsx @@ -0,0 +1,86 @@ +/** + * + * 页面布局入口文件 + * + * 说明: + * - rayLayoutContentWrapperScopeSelector: 页面切换时重置滚动条注入 id(弃用) + * + * 该组件入口不做逻辑相关的处理, 仅做功能、组件、方法注入 + * 提供页面内 Layout 的一些注入(css vars 为主) + */ + +import './index.scss' + +import { NLayout, NLayoutContent } from 'naive-ui' +import Menu from './components/Menu/index' +import SiderBar from './components/SiderBar/index' +import MenuTag from './components/MenuTag/index' +import ContentWrapper from '@/layout/default/ContentWrapper' +import FooterWrapper from '@/layout/default/FooterWrapper' + +import { useSetting } from '@/store' +import { LAYOUT_CONTENT_REF } from '@/appConfig/routerConfig' +import { layoutHeaderCssVars } from '@/layout/layoutResize' +import useAppLockScreen from '@/components/AppComponents/AppLockScreen/appLockVar' + +const Layout = defineComponent({ + name: 'RLayout', + setup() { + const layoutSiderBarRef = ref() + const layoutMenuTagRef = ref() + + const settingStore = useSetting() + + const { height: windowHeight } = useWindowSize() + const { menuTagSwitch: modelMenuTagSwitch } = storeToRefs(settingStore) + const { getLockAppScreen } = useAppLockScreen() + const cssVarsRef = layoutHeaderCssVars([ + layoutSiderBarRef, + layoutMenuTagRef, + ]) + + return { + windowHeight, + modelMenuTagSwitch, + cssVarsRef, + getLockAppScreen, + LAYOUT_CONTENT_REF, + layoutSiderBarRef, + layoutMenuTagRef, + } + }, + render() { + return ( +
+ {!this.getLockAppScreen() ? ( + + + + + {this.modelMenuTagSwitch ? ( + + ) : ( + '' + )} + + + + + + + ) : ( + '' + )} +
+ ) + }, +}) + +export default Layout diff --git a/src/layout/layoutResize.ts b/src/layout/layoutResize.ts new file mode 100644 index 00000000..b62f9c93 --- /dev/null +++ b/src/layout/layoutResize.ts @@ -0,0 +1,35 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import type { Ref } from 'vue' + +/** + * + * 动态获取 SiderBar 和 MenuTag 高度, 用于 LayoutConetent 高度实时获取与渲染 + * 可以动态更改 MenuTag 样式后, 使得 LayoutConetent 也可以准确的获取高度 + * + * 基于 vueuse useElementSize 方法实现 + * 不建议滥用该方法, 对页面渲染有一定的影响 + */ +export const layoutHeaderCssVars = ( + element: Ref[], +) => { + const siderBar = useElementSize(element[0]) + const menuTag = useElementSize(element[1]) + + return computed(() => { + return { + '--layout-content-height': `calc(100% - ${siderBar.height.value}px - ${menuTag.height.value}px)`, + '--layout-siderbar-height': `${siderBar.height.value}px`, + '--layout-menutag-height': `${menuTag.height.value}px`, + } + }) +} diff --git a/src/locales/README.md b/src/locales/README.md new file mode 100644 index 00000000..e9d1227d --- /dev/null +++ b/src/locales/README.md @@ -0,0 +1,34 @@ +## 国际化入口 + +### 说明 + +- 该文件入口为整个项目的入口文件 +- 二次封装 useI18n 方法、国际化文件入口、辅助方法等 +- 国际化配置文件格式都应该按照当前已约定格式进行拓展与使用 + +### lang 文件入口说明 + +> 项目默认包含英文包与中文包,如果需要拓展应该按照当前格式进行拓展。每个文件识别为一个国际包语言包(会自动导入所有 json 文件作为语言包)。 + +#### 注意 + +> 该项目语言包使用 json 格式作为语言包管理格式,为了配合 `@intlify/unplugin-vue-i18n/vite` 插件,故而采用 json。 + +#### 拓展方法 + +- 配置语言包文件(文件名为语言包名称) +- 配置文件入口(使用 `combineI18nMessages` 方法进行自动合并处理) +- 语言包名称应该全局唯一 + +### helper 文件说明 + +- `getAppLocalMessages` 获取系统所有语言包(该方法强制依赖 LOCAL_OPTIONS key 配置,意味着你在配置语言包的时候,key 必须与 `src/locales/lang/xxx.ts` 一一对应匹配) +- `naiveLocales` 获取 `naive-ui` 组件库对应语言包文件 +- `getAppDefaultLanguage` 获取系统当前默认语言包 + +### useI18n 文件说明 + +> 二次封装 `i18n`,应该避免使用自带 `useI18n` 方法,使用系统提供方法。 + +- 支持 setup 环境外使用 `t`、`value` 方法 +- 其行为与官方方法一致 diff --git a/src/locales/helper.ts b/src/locales/helper.ts new file mode 100644 index 00000000..66f722a3 --- /dev/null +++ b/src/locales/helper.ts @@ -0,0 +1,136 @@ +/** + * + * @author Ray + * + * @date 2023-05-19 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 国际化辅助方法: + * - combineI18nMessages: 合并对应文件下语言包 + * - getAppLocalMessages: 获取所有语言 + */ + +import { set } from 'lodash-es' +import { zhCN, dateZhCN } from 'naive-ui' // 导入 `naive ui` 中文包 +import { getStorage } from '@use-utils/cache' +import { SYSTEM_DEFAULT_LOCAL } from '@/appConfig/localConfig' +import { APP_CATCH_KEY } from '@/appConfig/appConfig' + +import type { Recordable } from '@/types/modules/helper' +import type { + AppLocalesModules, + AppLocalesDropdownMixedOption, + CurrentAppMessages, + I18nModules, +} from '@/locales/type' + +/** + * + * @param langs 语言包 + * @param prefix 语言包前缀 + * + * @remark 合并处理语言包内容, prefix 必填 + */ +export const combineI18nMessages = (langs: I18nModules, prefix: string) => { + if (typeof prefix !== 'string' || !prefix.trim()) { + throw new Error('Expected prefix to be a non-empty string') + } + + const langsGather: Record = {} + + Object.keys(langs).forEach((key) => { + const langFileModule = langs[key].default + + let fileName = key.replace(`./${prefix}/`, '').replace(/^\.\//, '') + const lastIndex = fileName.lastIndexOf('.') + + fileName = fileName.substring(0, lastIndex) + + const keyList = fileName.split('/') + const moduleName = keyList.shift() + const objKey = keyList.join('.') + + if (moduleName) { + if (objKey) { + set(langsGather, moduleName, langsGather[moduleName] || {}) + set(langsGather[moduleName], objKey, langFileModule) + } else { + set(langsGather, moduleName, langFileModule || {}) + } + } + }) + + return langsGather +} + +/** 获取所有语言 */ +export const getAppLocalMessages = async ( + LOCAL_OPTIONS: AppLocalesDropdownMixedOption[], +) => { + const message = {} as CurrentAppMessages + + for (const curr of LOCAL_OPTIONS) { + const msg: AppLocalesModules = await import(`./lang/${curr.key}.ts`) + const key = curr.key + + if (key) { + message[key] = msg?.default?.message ?? {} + } + } + + return message +} + +/** + * + * @param key 切换对应语言 + * @returns 组件库对应语言包 + * + * @remark 受打包体积影响. 如果有新的语言添加, 则需要手动引入对应语言包(https://www.naiveui.com/zh-CN/dark/docs/i18n) + * @remark naive ui 默认为英文 + * + * 该方法的比对 key 必须与 LOCAL_OPTIONS 一一对应 + */ +export const naiveLocales = (key: string) => { + switch (key) { + case 'zh-CN': + return { + locale: zhCN, + dateLocal: dateZhCN, + } + + case 'en-US': + return { + locale: null, + dateLocal: null, + } + + default: + return { + locale: zhCN, + dateLocal: dateZhCN, + } + } +} + +/** + * + * @returns 获取当前环境默认语言 + * + * @remak 未避免出现加载语言错误问题, 故而在 `main.ts` 注册时, 应优先加载 `i18n` 避免出现该问题 + */ +export const getAppDefaultLanguage = () => { + const language = getStorage( + APP_CATCH_KEY.localeLanguage, + 'localStorage', + SYSTEM_DEFAULT_LOCAL, + ) + + return language +} diff --git a/src/locales/index.ts b/src/locales/index.ts new file mode 100644 index 00000000..1901f192 --- /dev/null +++ b/src/locales/index.ts @@ -0,0 +1,60 @@ +/** + * + * @author Ray + * + * @date 2022-12-08 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 注册 `vue-i18n` + * + * 预设 `localeLanguage` 作为缓存 `key` + * + * 预设中文作为基础语言 + * + * `naive ui` 语言包切换 + * + * 注意: + * - 建议按照主流约定语言包命名 + */ + +import { createI18n } from 'vue-i18n' +import { LOCAL_OPTIONS } from '@/appConfig/localConfig' +import { getAppDefaultLanguage, getAppLocalMessages } from '@/locales/helper' + +import type { App } from 'vue' +import type { I18n, I18nOptions } from 'vue-i18n' + +/** i18n 实例 */ +export let i18n: I18n + +/** 创建 i18n 实例 */ +const createI18nOptions = async () => { + const locale = getAppDefaultLanguage() + const messages = await getAppLocalMessages(LOCAL_OPTIONS) + + const i18nInstance = createI18n({ + legacy: false, + locale, + messages: messages as unknown as I18nOptions['messages'], + sync: true, + missingWarn: false, + silentFallbackWarn: true, + }) + + return i18nInstance +} + +/** 注册 i18n */ +export const setupI18n = async (app: App) => { + const i18nInstance = await createI18nOptions() + + i18n = i18nInstance + + app.use(i18nInstance) +} diff --git a/src/locales/lang/en-US.ts b/src/locales/lang/en-US.ts new file mode 100644 index 00000000..01121baa --- /dev/null +++ b/src/locales/lang/en-US.ts @@ -0,0 +1,13 @@ +import { combineI18nMessages } from '@/locales/helper' + +import type { I18nModules } from '@/locales/type' + +const modules: I18nModules = import.meta.glob('./en-US/**/*.json', { + eager: true, +}) + +export default { + message: { + ...combineI18nMessages(modules, 'en-US'), + }, +} diff --git a/src/locales/lang/en-US/headerSettingOptions.json b/src/locales/lang/en-US/headerSettingOptions.json new file mode 100644 index 00000000..bf66ab47 --- /dev/null +++ b/src/locales/lang/en-US/headerSettingOptions.json @@ -0,0 +1,10 @@ +{ + "Title": "Configuration", + "ThemeOptions": { + "Title": "Theme", + "Dark": "Dark", + "Light": "Light", + "PrimaryColorConfig": "Primary Color" + }, + "InterfaceDisplay": "Display" +} diff --git a/src/locales/lang/en-US/headerTooltip.json b/src/locales/lang/en-US/headerTooltip.json new file mode 100644 index 00000000..819b1056 --- /dev/null +++ b/src/locales/lang/en-US/headerTooltip.json @@ -0,0 +1,9 @@ +{ + "Reload": "Reload Current Page", + "Lock": "Lock", + "Setting": "Setting", + "Github": "Github", + "FullScreen": "Full Screen", + "CancelFullScreen": "Cancel Full Screen", + "Search": "Search" +} diff --git a/src/locales/lang/en-US/menu.json b/src/locales/lang/en-US/menu.json new file mode 100644 index 00000000..3fc2dfac --- /dev/null +++ b/src/locales/lang/en-US/menu.json @@ -0,0 +1,20 @@ +{ + "Dashboard": "Home", + "Rely": "Rely", + "RelyAbout": "Rely About", + "Error": "Error Page", + "Echart": "Chart", + "scrollReveal": "Scroll Reveal", + "Axios": "Axios Request", + "Table": "Table", + "MultiMenu": "MultiMenu(catch)", + "Doc": "Doc", + "DocLocal": "Doc (China)", + "Office": "Office", + "Office_Document": "Document", + "Office_Presentation": "Presentation", + "Office_Spreadsheet": "Spreadsheet", + "CalculatePrecision": "Precision", + "Directive": "Directive", + "RouterDemo": "Same Level Router Demo" +} diff --git a/src/locales/lang/en-US/setting.json b/src/locales/lang/en-US/setting.json new file mode 100644 index 00000000..e00157a4 --- /dev/null +++ b/src/locales/lang/en-US/setting.json @@ -0,0 +1,10 @@ +{ + "Title": "Configuration", + "ThemeOptions": { + "Title": "Theme", + "Dark": "Dark", + "Light": "Light", + "PrimaryColorConfig": "Primary Color" + }, + "InterfaceDisplay": "Interface Display" +} diff --git a/src/locales/lang/en-US/views/login/index.json b/src/locales/lang/en-US/views/login/index.json new file mode 100644 index 00000000..efeece16 --- /dev/null +++ b/src/locales/lang/en-US/views/login/index.json @@ -0,0 +1,10 @@ +{ + "Register": "Register", + "Signin": "Signin", + "QRCodeSignin": "QRCode Signin", + "NamePlaceholder": "please enter user name", + "PasswordPlaceholder": "please enter password", + "Login": "Login", + "Name": "User Name", + "Password": "User Password" +} diff --git a/src/locales/lang/zh-CN.ts b/src/locales/lang/zh-CN.ts new file mode 100644 index 00000000..b1f4924b --- /dev/null +++ b/src/locales/lang/zh-CN.ts @@ -0,0 +1,13 @@ +import { combineI18nMessages } from '@/locales/helper' + +import type { I18nModules } from '@/locales/type' + +const modules: I18nModules = import.meta.glob('./zh-CN/**/*.json', { + eager: true, +}) + +export default { + message: { + ...combineI18nMessages(modules, 'zh-CN'), + }, +} diff --git a/src/locales/lang/zh-CN/headerSettingOptions.json b/src/locales/lang/zh-CN/headerSettingOptions.json new file mode 100644 index 00000000..1b8bd34c --- /dev/null +++ b/src/locales/lang/zh-CN/headerSettingOptions.json @@ -0,0 +1,10 @@ +{ + "Title": "项目配置", + "ThemeOptions": { + "Title": "主题", + "Dark": "暗色", + "Light": "明亮", + "PrimaryColorConfig": "主题色" + }, + "InterfaceDisplay": "界面显示" +} diff --git a/src/locales/lang/zh-CN/headerTooltip.json b/src/locales/lang/zh-CN/headerTooltip.json new file mode 100644 index 00000000..9cfd9a50 --- /dev/null +++ b/src/locales/lang/zh-CN/headerTooltip.json @@ -0,0 +1,9 @@ +{ + "Reload": "刷新当前页面", + "Lock": "锁屏", + "Setting": "设置", + "Github": "Github", + "FullScreen": "全屏", + "CancelFullScreen": "退出全屏", + "Search": "搜索" +} diff --git a/src/locales/lang/zh-CN/menu.json b/src/locales/lang/zh-CN/menu.json new file mode 100644 index 00000000..6af1d92f --- /dev/null +++ b/src/locales/lang/zh-CN/menu.json @@ -0,0 +1,20 @@ +{ + "Dashboard": "首页", + "Rely": "依赖项", + "RelyAbout": "关于", + "Error": "错误页", + "Echart": "可视化", + "scrollReveal": "滚动动画", + "Axios": "请求", + "Table": "表格", + "MultiMenu": "多级菜单(缓存)", + "Doc": "文档", + "DocLocal": "文档 (国内地址)", + "Office": "办公", + "Office_Document": "文档", + "Office_Presentation": "演示", + "Office_Spreadsheet": "表格", + "CalculatePrecision": "数字精度", + "Directive": "指令", + "RouterDemo": "页面详情模式" +} diff --git a/src/locales/lang/zh-CN/setting.json b/src/locales/lang/zh-CN/setting.json new file mode 100644 index 00000000..1b8bd34c --- /dev/null +++ b/src/locales/lang/zh-CN/setting.json @@ -0,0 +1,10 @@ +{ + "Title": "项目配置", + "ThemeOptions": { + "Title": "主题", + "Dark": "暗色", + "Light": "明亮", + "PrimaryColorConfig": "主题色" + }, + "InterfaceDisplay": "界面显示" +} diff --git a/src/locales/lang/zh-CN/views/login/index.json b/src/locales/lang/zh-CN/views/login/index.json new file mode 100644 index 00000000..a99252d3 --- /dev/null +++ b/src/locales/lang/zh-CN/views/login/index.json @@ -0,0 +1,10 @@ +{ + "Register": "注册", + "Signin": "登陆", + "QRCodeSignin": "扫码登陆", + "NamePlaceholder": "请输入用户名", + "PasswordPlaceholder": "请输入密码", + "Login": "登 陆", + "Name": "用户名", + "Password": "密码" +} diff --git a/src/locales/type.ts b/src/locales/type.ts new file mode 100644 index 00000000..03492fa1 --- /dev/null +++ b/src/locales/type.ts @@ -0,0 +1,26 @@ +import type { + DropdownOption, + DropdownGroupOption, + DropdownDividerOption, + DropdownRenderOption, +} from 'naive-ui' +import type { Recordable } from '@/types/modules/helper' + +export interface CurrentAppMessages { + 'zh-CN': object + 'en-US': object +} + +export type AppLocalesDropdownMixedOption = + | DropdownOption + | DropdownGroupOption + | DropdownDividerOption + | DropdownRenderOption + +export interface AppLocalesModules { + default: { + message: UnknownObjectKey + } +} + +export type I18nModules = Record diff --git a/src/locales/useI18n.ts b/src/locales/useI18n.ts new file mode 100644 index 00000000..32da32a6 --- /dev/null +++ b/src/locales/useI18n.ts @@ -0,0 +1,54 @@ +import { i18n } from './index' + +import type { WritableComputedRef } from 'vue' + +const getI18nKey = (namespace: string | undefined, key: string) => { + if (!namespace) { + return key + } + + if (key.startsWith(namespace)) { + return key + } + + return `${namespace}.${key}` +} + +export const useI18n = (namespace?: string) => { + const { t, locale, ...methods } = i18n.global + + const overridesTFunc = (key: string, ...args: any[]) => { + if (!key) { + return '' + } + + if (!key.includes('.') && !namespace) { + return key + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (t as any)(getI18nKey(namespace, key), ...args) + } + + /** 重写 locale 方法 */ + const overrideLocaleFunc = (lang: string) => { + const localeRef = locale as WritableComputedRef + + localeRef.value = lang + } + + return { + ...methods, + t: overridesTFunc, + locale: overrideLocaleFunc, + } +} + +/** + * + * 该方法为纯函数, 无任何副作用 + * 单纯为了配合 i18n-ally 插件使用 + * + * 该插件识别 t 方法包裹 path 进行提示文案内容 + */ +export const t = (key: string) => key diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..57fec277 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,73 @@ +import App from './App' + +import '@/styles/base.scss' + +import 'virtual:svg-icons-register' // `vite-plugin-svg-icons` 脚本, 如果不使用此插件注释即可 + +import { setupRouter } from './router/index' +import { setupStore } from './store/index' +import { setupI18n } from './locales/index' +import { setupDayjs } from './dayjs/index' +import { setupDirective } from './directives/index' + +import type { App as AppType } from 'vue' + +/** + * + * 普通应用注册方法 + */ +const setupTemplate = async () => { + const app = createApp(App) + + await setupI18n(app) + await setupStore(app) + setupRouter(app) + setupDayjs() + setupDirective(app) + + app.mount('#app') +} + +/** + * + * 作为 `wujie-micro` 子应用注册应用方法 + * 注意: 此处的 `instance` 名称不可以写为 `app` + */ +const setupWujieTemplate = async () => { + let instance: AppType + + window.__WUJIE_MOUNT = async () => { + instance = createApp(App) + + await setupI18n(instance) + await setupStore(instance) + setupRouter(instance) + setupDayjs() + + instance.mount('#app') + } + + window.__WUJIE_UNMOUNT = () => { + instance.unmount() + } + + window.__WUJIE.mount() +} + +/** + * + * 如果此处需要作为微服务主应用使用, 则只需要执行 `setupTemplate` 方法即可 + * 如果项目启用无界微服务, 会自动识别并且启动以无界微服务方法启动该项目 + * + * 作为主应用 + * ---------------------------------------------------------------- + * # 示例 + * const setupTemplate = () => { + * const app = createApp(App) + * setupRouter(app) + * ... + * } + * setupTemplate() + * ---------------------------------------------------------------- + */ +window.__POWERED_BY_WUJIE__ ? setupWujieTemplate() : setupTemplate() diff --git a/src/office/index.ts b/src/office/index.ts new file mode 100644 index 00000000..2ddf5541 --- /dev/null +++ b/src/office/index.ts @@ -0,0 +1,27 @@ +/** + * + * @author Ray + * + * @date 2023-03-22 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * onlyoffice + * + * 该功能暂未实现, 后续应该会补上 + * 由于该方法需要后端进行相关配合, 因为目前还在考虑是否接上私有 onlyoffice 服务器, 所以该功能暂未实现 + * 望多多理解, 理解万岁 + */ + +import { getAppEnvironment } from '@use-utils/hook' +import request from '@/axios/instance' + +export const getOfficeDocumentApi = async (uuid: string) => { + const { VITE_APP_OFFICE_PROXY_URL } = getAppEnvironment() + const { get } = request +} diff --git a/src/router/README.md b/src/router/README.md new file mode 100644 index 00000000..201d5866 --- /dev/null +++ b/src/router/README.md @@ -0,0 +1,119 @@ +## router 拓展 + +## 类型 + +```ts +interface RouteMeta { + order?: number + i18nKey: string + icon?: string + windowOpen?: string + role?: string[] + hidden?: boolean + noLocalTitle?: string | number + ignoreAutoResetScroll?: boolean + keepAlive?: boolean + sameLevel?: boolean +} +``` + +## 说明 + +``` +order: 菜单顺序,值越大越靠后。仅对顶层有效,子菜单该值无效 +i18nKey: i18n 国际化 key, 会优先使用该字段 +icon: icon 图标, 用于 Menu 菜单(依赖 RayIcon 组件实现) +windowOpen: 超链接打开(新开窗口打开) +role: 权限表 +hidden: 是否显示 +noLocalTitle: 不使用国际化渲染 Menu Titile +ignoreAutoResetScroll: 该页面内容区域自动初始化滚动条位置 +keepAlive: 是否缓存该页面(需要配置 APP_KEEP_ALIVE setupKeepAlive 属性为 true 启用才有效) +sameLevel: 是否标记该路由为平级模式,如果标记为平级模式,会使路由菜单项隐藏。如果在含有子节点处,设置了该属性,会导致子节点全部被隐藏。并且该模块,在后续的使用 url 地址导航跳转时,如果在非当前路由层级层面跳转的该路由,会在当前的面包屑后面追加该模块的信息,触发跳转时,不会修改面包屑、标签页 +``` + +### routerCopilot + +> 该文件提供了一些辅助方法,让你更方便的做一些事情。系统其他地方引用了该方法,所以删除需谨慎。 + +### useVueRouter + +> 二次封装了一个 router hook 方法,让你能够在 setup 环境之外使用 router。建议都使用该方法(useVueRouter)而不是 useRouter。 + +## 路由添加规则 + +- modules 中每一个 ts 文件视为一个路由模块 +- path 以 `/` 开头则视为根路由 +- 如果 path 为根路由,且不没有子级,则直接返回该路由 +- 如果 path 为根路由,且不含有子级,则拼接完整 path 路径,然后返回最后一层路由 +- 子级中不会存在 `/` 开头的情况(模板约定约束),如果存在则不用管,按照前三条逻辑执行代码,如果有误,由开发人员手动更改配置 + +```ts +const demo = { + path: '/multi', + name: 'MultiMenu', + meta: { + i18nKey: 'MultiMenu', + icon: 'table', + }, + children: [ + { + path: 'multi-menu-one', + name: 'MultiMenuOne', + meta: { + noLocalTitle: '多级菜单-1', + }, + key: 'multi-menu-one', + breadcrumbLabel: '多级菜单-1', + show: true, + }, + { + path: 'multi-menu-two', + name: 'MultiMenuTwo', + meta: { + noLocalTitle: '多级菜单-2', + }, + children: [ + { + path: 'sub-menu', + name: 'SubMenu', + meta: { + noLocalTitle: '多级菜单-2-1', + }, + key: 'sub-menu', + breadcrumbLabel: '多级菜单-2-1', + show: true, + }, + ], + key: 'multi-menu-two', + breadcrumbLabel: '多级菜单-2', + show: true, + }, + ], +} + +// 转换后 + +const transform = [ + { + path: '/multi/multi-menu-one', + name: 'MultiMenuOne', + meta: { + noLocalTitle: '多级菜单-1', + }, + key: 'multi-menu-one', + breadcrumbLabel: '多级菜单-1', + show: true, + }, + { + path: '/multi/multi-menu-two/sub-menu', + name: 'SubMenu', + meta: { + noLocalTitle: '多级菜单-2-1', + }, + key: 'sub-menu', + breadcrumbLabel: '多级菜单-2-1', + show: true, + }, +] +``` diff --git a/src/router/constant/index.ts b/src/router/constant/index.ts new file mode 100644 index 00000000..25093f6b --- /dev/null +++ b/src/router/constant/index.ts @@ -0,0 +1,19 @@ +/** + * + * default layout + * + * 默认布局, 统一使用该组件管理右侧现实内容区域展示 + * + * @example + * 使用示例: + * ``` + * { + * path: '/axios', + * name: 'Axios', + * component: LAYOUT, + * meta: { ... }, + * children: [ { ... } ] + * } + * ``` + */ +export const LAYOUT = () => import('@/layout/default/ContentWrapper/index') diff --git a/src/router/helper/expandRoutes.ts b/src/router/helper/expandRoutes.ts new file mode 100644 index 00000000..ed54cc95 --- /dev/null +++ b/src/router/helper/expandRoutes.ts @@ -0,0 +1,78 @@ +/** + * + * @author Ray + * + * @date 2023-06-01 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 该功能基于 代码改进实现 + * 自动展开所有路由 + */ + +import { cloneDeep } from 'lodash-es' + +import type { AppRouteRecordRaw } from '@/router/type' + +const isRootPath = (path: string) => path.startsWith('/') + +/** + * + * @param arr route modules + * @param result callback expand routes modules result + * @param path route path + * @returns callback expand routes modules result + * + * @remark 该方法会视 / 开头 path 为根路由 + */ +const routePromotion = ( + arr: AppRouteRecordRaw[], + result: AppRouteRecordRaw[] = [], + path = '', +) => { + // 如果没有小宝贝进来 则没有小宝贝出去 + if (!Array.isArray(arr)) { + return [] + } + + // 新来的小宝贝们先洗好澡澡哦 + const sourceArr = arr + + // 来开始我们的循环之旅吧 + sourceArr.forEach((curr) => { + // 获取可爱的小宝贝哦 + + if (curr.children?.length) { + // 如果小宝贝有小小宝贝 + + // 小宝贝们有孩子了,/(ㄒoㄒ)/~~ + routePromotion( + curr.children, + result, + path + (isRootPath(curr.path) ? curr.path : '/' + curr.path), + ) + } else { + // 小宝贝还是单身哦 + // 乖乖的小宝贝快快进入口袋 + curr.path = path + (isRootPath(curr.path) ? curr.path : '/' + curr.path) + + result.push(curr) + } + }) + + // 返回都是根节点的小宝贝们 + return result +} + +export const expandRoutes = (arr: AppRouteRecordRaw[]) => { + if (!Array.isArray(arr)) { + return [] + } + + return routePromotion(cloneDeep(arr)) +} diff --git a/src/router/helper/helper.ts b/src/router/helper/helper.ts new file mode 100644 index 00000000..7192d914 --- /dev/null +++ b/src/router/helper/helper.ts @@ -0,0 +1,109 @@ +/** + * + * @author Ray + * + * @date 2023-07-04 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * helper 包入口 + * + * 该包一般是用于该模块一些处理的辅助方法 + * 通常不会用于其他地方 + * 如果有需要查看 router 模块的全局通用辅助方法可以查看 routerCopilot 包 + */ + +import { LAYOUT_CONTENT_REF } from '@/appConfig/routerConfig' + +import type { RouteLocationNormalized } from 'vue-router' +import type { AppRouteRecordRaw, RouteModules } from '@/router/type' + +/** + * + * @returns 所有路由模块 + * + * @remark 自动合并所有路由模块, 每一个 ts 文件都视为一个 route module 与 views 一一对应 + * + * 请注意, 如果更改了 modules 的目录位置或者该方法的位置, 需要同步更改 import.meta.glob 方法的路径 + * 该方法会以本文件为起始位置去查找对应 URL 目录的资源 + * + * 会将 modules 中每一个 ts 文件当作一个路由模块, 即使你以分包的形式创建了路由模块 + */ +export const combineRawRouteModules = () => { + const modulesFiles: RouteModules = import.meta.glob('../modules/**/*.ts', { + eager: true, + }) + + const modules = Object.keys(modulesFiles).reduce((modules, modulePath) => { + const route = modulesFiles[modulePath].default + + if (route) { + modules.push(route) + } else { + throw new Error( + 'router helper combine: an exception occurred while parsing the routing file!', + ) + } + + return modules + }, [] as AppRouteRecordRaw[]) + + return modules +} + +/** + * + * @param routes 路由模块表(route 表) + * @returns 排序后的新路由表 + * + * @remark 必须配置 meta 属性, order 属性会影响页面菜单排序 + * + * 如果为配置 order 属性, 则会自动按照前合并路由的顺序前后排序 + * 如果 order 属性值相同, 则会按照路由名称进行排序 + */ +export const orderRoutes = (routes: AppRouteRecordRaw[]) => { + return routes.sort((curr, next) => { + const currOrder = curr.meta?.order ?? 1 + const nextOrder = next.meta?.order ?? 0 + + if (typeof currOrder !== 'number' || typeof nextOrder !== 'number') { + throw new Error('orderRoutes error: order must be a number!') + } + + if (currOrder === nextOrder) { + // 如果两个路由的 order 值相同,则按照路由名进行排序 + return curr.name + ? next.name + ? curr.name.localeCompare(next.name) + : -1 + : 1 + } + + return currOrder - nextOrder + }) +} + +/** + * + * 切换路由时, 手动将容器区域回归默认值 + * + * 由于官方不支持这个方法了, 所以自己手写了一个 + * 如果需要忽略恢复默认位置, 仅需要在 meta 中配置 ignoreAutoResetScroll 属性即可 + */ +export const scrollViewToTop = (route: RouteLocationNormalized) => { + const { meta } = route + + /** 这个 id 是注入在 layout 中 */ + if (!meta?.ignoreAutoResetScroll) { + LAYOUT_CONTENT_REF.value?.scrollTo({ + top: 0, + left: 0, + behavior: 'smooth', + }) + } +} diff --git a/src/router/helper/permission.ts b/src/router/helper/permission.ts new file mode 100644 index 00000000..f608398b --- /dev/null +++ b/src/router/helper/permission.ts @@ -0,0 +1,96 @@ +/** + * + * @author Ray + * + * @date 2023-01-28 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 路由守卫 + * 进行路由鉴权操作 + * + * 根据 meta role 与 BASIC_ROUTER 结合进行跳转路由鉴权操作 + * 如果 meta role 为空则会默认认为全局可用 + * 如果需要指定角色, 则添加该属性并且添加角色 + * 当然, 你可以指定一个超级管理员角色, 默认获取全部路由 + */ + +import { getStorage } from '@/utils/cache' +import { APP_CATCH_KEY, ROOT_ROUTE } from '@/appConfig/appConfig' +import { redirectRouterToDashboard } from '@/router/helper/routerCopilot' +import { WHITE_ROUTES } from '@/appConfig/routerConfig' +import { validRole } from '@/router/helper/routerCopilot' +import { isValueType } from '@/utils/hook' + +import type { + Router, + NavigationGuardNext, + RouteLocationNormalized, +} from 'vue-router' +import type { AppMenuOption } from '@/types/modules/app' +import type { AppRouteMeta } from '@/router/type' + +/** 路由守卫 */ +export const permissionRouter = (router: Router) => { + const { beforeEach } = router + + const isToLogin = ( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + ) => to.path === '/' || from.path === '/login' + + beforeEach((to, from, next) => { + const token = getStorage(APP_CATCH_KEY.token) + const catchRoutePath = getStorage( + 'menuKey', + 'sessionStorage', + ROOT_ROUTE.path, + ) + const { meta, name } = to + + /** 是否含有 token */ + if (token !== null) { + /** 是否在有 token 时去到登陆页 */ + if (isToLogin(to, from)) { + redirectRouterToDashboard(true) + } else { + /** 是否为白名单 */ + if ( + !isValueType(name, 'Symbol') && + name && + WHITE_ROUTES.includes(name) + ) { + next() + } else { + /** 是否有权限 */ + if (validRole(meta as AppRouteMeta)) { + /** 是否在有权限时去到登陆页 */ + if (isToLogin(to, from)) { + /** 容错处理, 如果没有预设地址与获取到缓存地址, 则重定向到首页去 */ + if (catchRoutePath) { + next(catchRoutePath) + } else { + redirectRouterToDashboard(true) + } + } else { + next() + } + } else { + redirectRouterToDashboard(true) + } + } + } + } else { + if (isToLogin(to, from)) { + next() + } else { + next('/') + } + } + }) +} diff --git a/src/router/helper/routerCopilot.ts b/src/router/helper/routerCopilot.ts new file mode 100644 index 00000000..4caec228 --- /dev/null +++ b/src/router/helper/routerCopilot.ts @@ -0,0 +1,132 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { permissionRouter } from './permission' + +import { + SETUP_ROUTER_LOADING_BAR, + SETUP_ROUTER_GUARD, + SUPER_ADMIN, +} from '@/appConfig/routerConfig' +import { useSignin } from '@/store' +import { useVueRouter } from '@/router/helper/useVueRouter' +import { ROOT_ROUTE } from '@/appConfig/appConfig' +import { setStorage } from '@/utils/cache' + +import type { Router } from 'vue-router' +import type { AppRouteMeta } from '@/router/type' +import type { AppMenuOption } from '@/types/modules/app' + +/** + * + * 校验当前菜单项是否与权限匹配 + * 仅做对于 Meta Role 配置是否匹配做校验, 不关心 Meta Hidden 属性 + * + * 如果为超级管理员, 则会默认获取所有权限 + */ +export const validRole = (meta: AppRouteMeta) => { + const { signinCallback } = storeToRefs(useSignin()) + const modelRole = computed(() => signinCallback.value.role) + const { role: metaRole } = meta + + if (SUPER_ADMIN?.length && SUPER_ADMIN.includes(modelRole.value)) { + return true + } else { + // 如果 role 为 undefind 或者空数组, 则认为该路由不做权限过滤 + if (!metaRole || !metaRole?.length) { + return true + } + + // 判断是否含有该权限 + if (metaRole) { + return metaRole.includes(modelRole.value) + } + + return true + } +} + +/** + * + * @remark 校验当前路由是否显示 + * + * 该方法进行校验时, 会将 hidden 与 sameLevel 一起进行校验 + * sameLevel 的优先级最高 + * + * 如果你仅仅是希望校验是否满足权限, 应该使用另一个方法 validRole + */ +export const validMenuItemShow = (option: AppMenuOption) => { + const { meta = {} } = option + const { hidden, sameLevel } = meta + + // 如果该路由被标记为平级模式, 则会强制不显示在菜单中 + if (sameLevel) { + return false + } + + if (hidden) { + return false + } + + return true +} + +/** + * + * @remark 路由切换启用顶部加载条 + * @remark 路由切换启用加载动画 + */ +export const setupRouterLoadingBar = (router: Router) => { + router.beforeEach(() => { + window?.$loadingBar?.start() + }) + + router.afterEach(() => { + window?.$loadingBar?.finish() + }) + + router.onError(() => { + window?.$loadingBar?.error() + }) +} + +/** + * + * @param router vue router instance + * + * @remark 统一的路由相关功能配置, 虽然该方法有点蠢... + */ +export const vueRouterRegister = (router: Router) => { + if (SETUP_ROUTER_LOADING_BAR) { + setupRouterLoadingBar(router) + } + + if (SETUP_ROUTER_GUARD) { + permissionRouter(router) + } +} + +/** + * + * @param replace 是否使用 + * + * @remark 重定向路由至首页, 默认采用替换方法重定向 + */ +export const redirectRouterToDashboard = (isReplace = true) => { + const { router } = useVueRouter() + + const { push, replace } = router + const { path } = ROOT_ROUTE + + setStorage('menuKey', path) + + isReplace ? push(path) : replace(path) +} diff --git a/src/router/helper/useVueRouter.ts b/src/router/helper/useVueRouter.ts new file mode 100644 index 00000000..5f8465c4 --- /dev/null +++ b/src/router/helper/useVueRouter.ts @@ -0,0 +1,35 @@ +/** + * + * @author Ray + * + * @date 2023-06-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { router } from '@/router/index' + +/** + * + * @returns vue router instance + * + * @remark 使用 vue router instance, 可以在 setup 环境外使用 + * + * 使用该方法时候, 可能会出现热更新错误的问题... 所以遇到的时候不要紧张, 刷新一下就好 + * 如果确定使用环境就在 setup 中, 还是建议使用官方的 useRouter useRoute 方法, 避免热更新报错的问题 + */ +export const useVueRouter = () => { + try { + if (router) { + return { + router, + } + } else { + throw new Error() + } + } catch (e) { + throw new Error('router is not defined') + } +} diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 00000000..4d5a4259 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,36 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import { scrollViewToTop } from '@/router/helper/helper' +import { vueRouterRegister } from '@/router/helper/routerCopilot' +import { useVueRouter } from '@/router/helper/useVueRouter' + +import constantRoutes from './routes' + +import type { App } from 'vue' +import type { RouteRecordRaw, Router } from 'vue-router' + +export let router: Router + +const createVueRouter = () => { + return createRouter({ + history: createWebHashHistory(), + routes: constantRoutes() as unknown as RouteRecordRaw[], + scrollBehavior: (to) => { + scrollViewToTop(to) + + return { + top: 0, + left: 0, + } + }, + }) +} + +// setup router +export const setupRouter = (app: App) => { + router = createVueRouter() + + vueRouterRegister(router) + useVueRouter() + + app.use(router) +} diff --git a/src/router/modules/axios.ts b/src/router/modules/axios.ts new file mode 100644 index 00000000..79bd211b --- /dev/null +++ b/src/router/modules/axios.ts @@ -0,0 +1,18 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const axios: AppRouteRecordRaw = { + path: '/axios', + name: 'RAxios', + component: () => import('@/views/axios/index'), + meta: { + i18nKey: t('menu.Axios'), + icon: 'axios', + order: 3, + keepAlive: true, + }, +} + +export default axios diff --git a/src/router/modules/dashboard.ts b/src/router/modules/dashboard.ts new file mode 100644 index 00000000..bba64493 --- /dev/null +++ b/src/router/modules/dashboard.ts @@ -0,0 +1,17 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const dashboard: AppRouteRecordRaw = { + path: '/dashboard', + name: 'RDashboard', + component: () => import('@/views/dashboard/index'), + meta: { + i18nKey: t('menu.Dashboard'), + icon: 'dashboard', + order: 0, + }, +} + +export default dashboard diff --git a/src/router/modules/directive.ts b/src/router/modules/directive.ts new file mode 100644 index 00000000..45611ad1 --- /dev/null +++ b/src/router/modules/directive.ts @@ -0,0 +1,17 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const directive: AppRouteRecordRaw = { + path: '/directive', + name: 'RDirective', + component: () => import('@/views/directive/index'), + meta: { + i18nKey: t('menu.Directive'), + icon: 'other', + order: 2, + }, +} + +export default directive diff --git a/src/router/modules/doc-local.ts b/src/router/modules/doc-local.ts new file mode 100644 index 00000000..c65ba3c8 --- /dev/null +++ b/src/router/modules/doc-local.ts @@ -0,0 +1,18 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const docLocal: AppRouteRecordRaw = { + path: '/doc', + name: 'RDocLocal', + component: () => import('@/views/doc/index'), + meta: { + i18nKey: t('menu.DocLocal'), + icon: 'doc', + windowOpen: 'https://ray-template.yunkuangao.com/ray-template-doc/', + order: 6, + }, +} + +export default docLocal diff --git a/src/router/modules/doc.ts b/src/router/modules/doc.ts new file mode 100644 index 00000000..a4e65910 --- /dev/null +++ b/src/router/modules/doc.ts @@ -0,0 +1,18 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const doc: AppRouteRecordRaw = { + path: '/doc', + name: 'RDoc', + component: () => import('@/views/doc/index'), + meta: { + i18nKey: t('menu.Doc'), + icon: 'doc', + windowOpen: 'https://xiaodaigua-ray.github.io/ray-template-doc/', + order: 5, + }, +} + +export default doc diff --git a/src/router/modules/echart.ts b/src/router/modules/echart.ts new file mode 100644 index 00000000..c287115e --- /dev/null +++ b/src/router/modules/echart.ts @@ -0,0 +1,17 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const echart: AppRouteRecordRaw = { + path: '/echart', + name: 'REchart', + component: () => import('@/views/echart/index'), + meta: { + i18nKey: t('menu.Echart'), + icon: 'echart', + order: 1, + }, +} + +export default echart diff --git a/src/router/modules/error.ts b/src/router/modules/error.ts new file mode 100644 index 00000000..62e0c690 --- /dev/null +++ b/src/router/modules/error.ts @@ -0,0 +1,17 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const error: AppRouteRecordRaw = { + path: '/error', + name: 'ErrorPage', + component: () => import('@/error/views/Error404/index'), + meta: { + i18nKey: t('menu.Error'), + icon: 'error', + hidden: true, + }, +} + +export default error diff --git a/src/router/modules/iframe.ts b/src/router/modules/iframe.ts new file mode 100644 index 00000000..6fdf5064 --- /dev/null +++ b/src/router/modules/iframe.ts @@ -0,0 +1,16 @@ +import { t } from '@/locales/useI18n' + +import type { AppRouteRecordRaw } from '@/router/type' + +const iframe: AppRouteRecordRaw = { + path: '/iframe', + name: 'IframeDemo', + component: () => import('@/views/iframe/index'), + meta: { + icon: 'other', + order: 2, + noLocalTitle: 'iframe', + }, +} + +export default iframe diff --git a/src/router/modules/multi-menu.ts b/src/router/modules/multi-menu.ts new file mode 100644 index 00000000..ab6149e7 --- /dev/null +++ b/src/router/modules/multi-menu.ts @@ -0,0 +1,73 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const multiMenu: AppRouteRecordRaw = { + path: '/multi', + name: 'MultiMenu', + component: LAYOUT, + meta: { + i18nKey: t('menu.MultiMenu'), + icon: 'other', + order: 4, + }, + children: [ + { + path: 'multi-menu-one', + name: 'MultiMenuOne', + component: () => import('@/views/multi/views/multi-menu-one/index'), + meta: { + noLocalTitle: '多级菜单-1', + keepAlive: true, + }, + }, + { + path: 'multi-menu-two', + name: 'MultiMenuTwo', + component: LAYOUT, + meta: { + noLocalTitle: '多级菜单-2', + }, + children: [ + { + path: 'sub-menu-other', + name: 'SubMenuOther', + component: () => + import( + '@/views/multi/views/multi-menu-two/views/sub-menu-other/index' + ), + meta: { + noLocalTitle: '多级菜单-2-1', + keepAlive: true, + }, + }, + { + path: 'sub-menu', + name: 'SubMenu', + component: LAYOUT, + meta: { + noLocalTitle: '多级菜单-2-2', + keepAlive: true, + }, + children: [ + { + path: 'sub-menu-one', + name: 'MultiMenuTwoOne', + component: () => + import( + '@/views/multi/views/multi-menu-two/views/sub-menu/views/multi-menu-two-one/index' + ), + meta: { + noLocalTitle: '多级菜单-2-2-1', + keepAlive: true, + }, + }, + ], + }, + ], + }, + ], +} + +export default multiMenu diff --git a/src/router/modules/office.ts b/src/router/modules/office.ts new file mode 100644 index 00000000..55c2466f --- /dev/null +++ b/src/router/modules/office.ts @@ -0,0 +1,43 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const office: AppRouteRecordRaw = { + path: '/office', + name: 'ROffice', + component: () => import('@/views/office/index'), + meta: { + i18nKey: t('menu.Office'), + icon: 'office', + hidden: true, + }, + children: [ + { + path: 'document', + name: 'Document', + component: () => import('@/views/office/views/document/index'), + meta: { + i18nKey: 'Office_Document', + }, + }, + { + path: 'presentation', + name: 'Presentation', + component: () => import('@/views/office/views/presentation/index'), + meta: { + i18nKey: 'Office_Presentation', + }, + }, + { + path: 'spreadsheet', + name: 'Spreadsheet', + component: () => import('@/views/office/views/spreadsheet/index'), + meta: { + i18nKey: 'Office_Spreadsheet', + }, + }, + ], +} + +export default office diff --git a/src/router/modules/precision.ts b/src/router/modules/precision.ts new file mode 100644 index 00000000..0dba74df --- /dev/null +++ b/src/router/modules/precision.ts @@ -0,0 +1,17 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const precision: AppRouteRecordRaw = { + path: '/precision', + name: 'CalculatePrecision', + component: () => import('@/views/precision/index'), + meta: { + i18nKey: t('menu.CalculatePrecision'), + icon: 'other', + order: 2, + }, +} + +export default precision diff --git a/src/router/modules/rely.ts b/src/router/modules/rely.ts new file mode 100644 index 00000000..1cebef37 --- /dev/null +++ b/src/router/modules/rely.ts @@ -0,0 +1,27 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const rely: AppRouteRecordRaw = { + path: '/rely', + name: 'RelyAbout', + component: LAYOUT, + meta: { + i18nKey: t('menu.Rely'), + icon: 'rely', + order: 7, + }, + children: [ + { + path: 'rely-about', + name: 'RelyAbout', + component: () => import('@/views/rely/views/rely-about/index'), + meta: { + i18nKey: 'RelyAbout', + }, + }, + ], +} + +export default rely diff --git a/src/router/modules/router-demo.ts b/src/router/modules/router-demo.ts new file mode 100644 index 00000000..192078c1 --- /dev/null +++ b/src/router/modules/router-demo.ts @@ -0,0 +1,37 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const routerDemo: AppRouteRecordRaw = { + path: '/router-demo', + name: 'RouterDemoRoot', + component: LAYOUT, + meta: { + i18nKey: t('menu.RouterDemo'), + icon: 'other', + order: 3, + }, + children: [ + { + path: 'router-demo-home', + name: 'RouterDemoHome', + component: () => import('@/views/router-demo/router-demo-home/index'), + meta: { + noLocalTitle: '人员信息(平级模式)', + }, + }, + { + path: 'router-demo-detail', + name: 'RouterDemoDetail', + component: () => import('@/views/router-demo/router-demo-detail/index'), + meta: { + noLocalTitle: '信息详情', + hidden: true, + sameLevel: true, + }, + }, + ], +} + +export default routerDemo diff --git a/src/router/modules/scroll-reveal.ts b/src/router/modules/scroll-reveal.ts new file mode 100644 index 00000000..ca53f267 --- /dev/null +++ b/src/router/modules/scroll-reveal.ts @@ -0,0 +1,23 @@ +/** + * + * 由于还未找到如何解决 scrollReveal 插件问题 + * 所以暂时隐藏该页面 + */ + +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const scrollReveal: AppRouteRecordRaw = { + path: '/scroll-reveal', + name: 'ScrollReveal', + component: () => import('@/views/scroll-reveal/index'), + meta: { + i18nKey: t('menu.scrollReveal'), + icon: 'scroll_reveal', + hidden: true, + }, +} + +export default scrollReveal diff --git a/src/router/modules/table.ts b/src/router/modules/table.ts new file mode 100644 index 00000000..a25e84b2 --- /dev/null +++ b/src/router/modules/table.ts @@ -0,0 +1,17 @@ +import { t } from '@/locales/useI18n' +import { LAYOUT } from '@/router/constant/index' + +import type { AppRouteRecordRaw } from '@/router/type' + +const table: AppRouteRecordRaw = { + path: '/table', + name: 'TableView', + component: () => import('@/views/table/index'), + meta: { + i18nKey: t('menu.Table'), + icon: 'other', + order: 2, + }, +} + +export default table diff --git a/src/router/routeModules.ts b/src/router/routeModules.ts new file mode 100644 index 00000000..6dd7edfa --- /dev/null +++ b/src/router/routeModules.ts @@ -0,0 +1,27 @@ +/** + * + * @author Ray + * + * @date 2023-06-01 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 描述: + * - 自动导入所有路由模块 + * - 平铺所有路由 + * + * modules 模块下每一个 ts 文件视为一个路由模块(route) + * 每个模块必须配置 meta 属性 + * 如果不设置 order 属性, 则会默认排在前面 + */ + +import { combineRawRouteModules } from '@/router/helper/helper' +import { orderRoutes } from '@/router/helper/helper' + +/** 获取所有被合并与排序的路由 */ +export const getAppRawRoutes = () => orderRoutes(combineRawRouteModules()) diff --git a/src/router/routes.ts b/src/router/routes.ts new file mode 100644 index 00000000..37bc076d --- /dev/null +++ b/src/router/routes.ts @@ -0,0 +1,27 @@ +import Layout from '@/layout/index' +import { getAppRawRoutes } from './routeModules' +import { ROOT_ROUTE } from '@/appConfig/appConfig' +import { expandRoutes } from '@/router/helper/expandRoutes' + +const { path } = ROOT_ROUTE + +export default () => [ + { + path: '/', + name: 'login', + component: () => import('@/views/login/index'), + }, + { + path: '/', + name: 'layout', + redirect: path, + component: Layout, + children: expandRoutes(getAppRawRoutes()), + }, + { + path: '/:catchAll(.*)', + name: 'errorPage', + component: Layout, + redirect: '/error', + }, +] diff --git a/src/router/type.ts b/src/router/type.ts new file mode 100644 index 00000000..6625f92e --- /dev/null +++ b/src/router/type.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { RouteRecordRaw } from 'vue-router' +import type { Recordable } from '@/types/modules/helper' +import type { DefineComponent, VNode } from 'vue' + +export type Component = + | DefineComponent<{}, {}, any> + | (() => Promise) + | (() => Promise) + +export interface AppRouteMeta { + i18nKey?: string + icon?: string | VNode + windowOpen?: string + role?: (string | number)[] + hidden?: boolean + noLocalTitle?: string | number + ignoreAutoResetScroll?: boolean + order?: number + keepAlive?: boolean + sameLevel?: boolean +} + +// @ts-ignore +export interface AppRouteRecordRaw extends Omit { + name: string + meta: AppRouteMeta + component?: Component | string + components?: Component + children?: AppRouteRecordRaw[] + props?: Recordable + fullPath?: string +} + +export interface RouteModules { + [propName: string]: { + default: AppRouteRecordRaw + } +} diff --git a/src/spin/hook.ts b/src/spin/hook.ts new file mode 100644 index 00000000..73d89f25 --- /dev/null +++ b/src/spin/hook.ts @@ -0,0 +1,9 @@ +export const spinValue = ref(false) + +/** + * + * @param bool has spin + * + * @remark 使用 spin 全屏加载效果工具函数 + */ +export const setSpin = (bool: boolean) => (spinValue.value = bool) diff --git a/src/spin/index.tsx b/src/spin/index.tsx new file mode 100644 index 00000000..93abe313 --- /dev/null +++ b/src/spin/index.tsx @@ -0,0 +1,64 @@ +/** + * + * @author Ray + * + * @date 2023-01-18 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 全屏加载效果 + * + * 基于 Naive UI Spin 组件 + * + * 使用方法 + * 1. import { setSpin } from '@/spin' + * 2. setSpin(true) | setSpin(false) + * + * 仅需按照上述步骤实现全屏加载动画 + * + * 注意 + * 1. 该组件为全屏加载动画效果, 其遮罩会导致页面元素不可被命中 + * 2. 如果需要使用该组件请注意控制取消时机 + */ + +import { NSpin } from 'naive-ui' + +import { spinProps } from 'naive-ui' +import { spinValue } from './hook' + +export { setSpin } from './hook' + +const GlobalSpin = defineComponent({ + name: 'GlobalSpin', + props: { + ...spinProps, + }, + setup() { + const overrides = { + opacitySpinning: '0.3', + } + + return { + spinValue, + overrides, + } + }, + render() { + return ( + + {{ ...this.$slots }} + + ) + }, +}) + +export default GlobalSpin diff --git a/src/store/README.md b/src/store/README.md new file mode 100644 index 00000000..c33b32a9 --- /dev/null +++ b/src/store/README.md @@ -0,0 +1,29 @@ +## 描述 + +> pinia store 仓库包。存放全局公共状态。 + +## 约束 + +- 状态管理器应该按照其用途进行分包(见名知意) +- 包名以用途名命名 + - 默认以 index.ts 作为入口,其余的辅助函数、类型,分别在该文件夹下进行补充(type.ts、helper.ts。。。) +- 仓库使用 `piniaPluginPersistedstate` 作为中间件,用于缓存仓库数据避免刷新丢失(但是该方法有缺陷,不能缓存函数) + - 默认不全部缓存参数,如果需要缓存参数,需要在 `defineStore` 第三个参数配置 `persist` 属性 + - `defineStore` 第一个参数必须全局唯一 + - 缓存插件 key 应该按照 `piniaXXXStore` 格式命名(XXX 表示该包名称) + +```ts +export const useDemoStore = defineStore('demo', () => {}, { + persist: { + key: 'piniaDemoStore', + paths: ['demoState'], + storage: sessionStorage | localStorage, + }, +}) +``` + +- 最后在 index.ts 中暴露使用 + +```ts +export { useDemo } from './modules/demo/index' +``` diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 00000000..2028ee18 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,35 @@ +/** + * + * @author Ray + * + * @date 2023-01-03 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 持久化存储 pinia 数据 + * 但是不能正常持久化 function 属性 + * + * 官网地址: https://prazdevs.github.io/pinia-plugin-persistedstate/zh/guide/ + */ +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' + +export { useSetting } from './modules/setting/index' // import { useSetting } from '@/store' 即可使用 +export { useMenu } from './modules/menu/index' +export { useSignin } from './modules/signin/index' +export { useKeepAlive } from './modules/keep-alive/index' + +import type { App } from 'vue' + +/** 设置并且注册 pinia */ +export const setupStore = async (app: App) => { + const store = createPinia() + + app.use(store) + + store.use(piniaPluginPersistedstate) +} diff --git a/src/store/modules/keep-alive/index.ts b/src/store/modules/keep-alive/index.ts new file mode 100644 index 00000000..af699bfc --- /dev/null +++ b/src/store/modules/keep-alive/index.ts @@ -0,0 +1,80 @@ +/** + * + * @author Ray + * + * @date 2023-06-01 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 缓存 + * + * 管理系统缓存 + * 基于 KeepAlive 组件实现 + * 依赖 APP_KEEP_ALIVE 配置 + */ + +import { APP_KEEP_ALIVE } from '@/appConfig/appConfig' + +import type { KeepAliveStoreState } from './type' +import type { AppMenuOption } from '@/types/modules/app' + +export const useKeepAlive = defineStore( + 'keepAlive', + () => { + const { maxKeepAliveLength } = APP_KEEP_ALIVE + + const state = reactive({ + keepAliveInclude: [], + }) + + const getCurrentKeepAliveLength = () => state.keepAliveInclude.length + + /** + * + * @param option current menu option + * + * @remark 判断当前页面是否配置需要缓存, 并且判断当前缓存数量是否超过最大缓存数设置数量 + * @remark 如果超过最大阈值, 则会按照尾插头删方式维护该队列 + */ + const setKeepAliveInclude = (option: AppMenuOption) => { + const length = getCurrentKeepAliveLength() + const { + name, + meta: { keepAlive }, + } = option + + if (keepAlive) { + if ( + length < maxKeepAliveLength && + !state.keepAliveInclude.includes(name) + ) { + state.keepAliveInclude.push(name) + + return + } + + if (length >= maxKeepAliveLength) { + state.keepAliveInclude.splice(0, 1) + state.keepAliveInclude.push(name) + } + } + } + + return { + ...toRefs(state), + setKeepAliveInclude, + } + }, + { + persist: { + key: 'piniaKeepAliveStore', + storage: window.sessionStorage, + paths: ['keepAliveInclude'], + }, + }, +) diff --git a/src/store/modules/keep-alive/type.ts b/src/store/modules/keep-alive/type.ts new file mode 100644 index 00000000..2c99f3ec --- /dev/null +++ b/src/store/modules/keep-alive/type.ts @@ -0,0 +1,3 @@ +export interface KeepAliveStoreState { + keepAliveInclude: string[] +} diff --git a/src/store/modules/menu/helper.ts b/src/store/modules/menu/helper.ts new file mode 100644 index 00000000..c5d400af --- /dev/null +++ b/src/store/modules/menu/helper.ts @@ -0,0 +1,184 @@ +/** + * + * @author Ray + * + * @date 2023-03-03 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** 本方法感谢 的支持 */ + +import { APP_MENU_CONFIG, ROOT_ROUTE } from '@/appConfig/appConfig' +import RayIcon from '@/components/RayIcon/index' +import { isValueType } from '@/utils/hook' +import { getStorage, setStorage } from '@/utils/cache' + +import type { VNode } from 'vue' +import type { + AppMenuOption, + MenuTagOptions, + AppMenuKey, +} from '@/types/modules/app' + +/** + * + * @param node 当前节点 + * @param key 动态字段 + * @param value 匹配值 + * + * @remark 检查是否为所需项 + */ +const isMatch = ( + node: AppMenuOption, + key: string | number, + value: string | number, +) => { + if (!node || typeof node !== 'object') { + return false + } + + if (node[key] === value) { + return true + } + + return false +} + +/** + * + * @param options 节点数组 + * @param key 动态字段 + * @param value 匹配值 + * + * @remark 匹配所有节点 + */ +const findMatchingNodes = ( + options: AppMenuOption, + key: string | number, + value: string | number, +) => { + const temp: AppMenuOption[] = [] + + // 检查当前节点是否匹配值 + if (isMatch(options, key, value)) { + temp.push(options) + + return temp + } + + // 遍历子节点 + if (options.children && options.children.length > 0) { + for (const it of options.children) { + // 子节点递归调用 + const innerTemp = findMatchingNodes(it, key, value) + + // 如果子节点匹配到了,则将当前节点加入数组 + if (innerTemp.length > 0) { + temp.push(options, ...innerTemp) + } + } + } + + return temp +} + +/** + * + * @param options 节点数组 + * @param key 动态字段 + * @param value 匹配值 + */ +export const parseAndFindMatchingNodes = ( + options: AppMenuOption[], + key: string | number, + value: string | number, +) => { + const temp = [] + + for (const it of options) { + const innerTemp = findMatchingNodes(it, key, value) + + if (innerTemp.length > 0) { + temp.push(...innerTemp) + } + } + + return temp +} + +/** + * + * @param item menu options + * @param key current menu key + * @param menuTagOptions menu tag options + * + * @remark 查找当前菜单项 + */ +export const matchMenuOption = ( + item: AppMenuOption, + key: AppMenuKey, + menuTagOptions: MenuTagOptions[], +) => { + if (item.path !== key) { + const tag = menuTagOptions.find((curr) => curr.path === item.path) + + if (!tag) { + menuTagOptions.push(item) + } + } +} + +/** + * + * @param option menu option + * + * @remark 动态修改浏览器标题 + * @remark 会自动拼接 sideBarLogo.title + */ +export const updateDocumentTitle = (option: AppMenuOption) => { + const { breadcrumbLabel } = option + const { + layout: { sideBarLogo }, + } = __APP_CFG__ + const spliceTitle = sideBarLogo ? sideBarLogo.title : '' + + document.title = breadcrumbLabel + ' - ' + spliceTitle +} + +export const hasMenuIcon = (option: AppMenuOption) => { + const { meta } = option + + if (!meta.icon) { + return + } + + if (isValueType(meta.icon, 'Object')) { + return () => meta.icon + } + + const icon = h( + RayIcon, + { + name: meta!.icon as string, + size: APP_MENU_CONFIG.MENU_COLLAPSED_ICON_SIZE, + }, + {}, + ) + + return () => icon +} + +/** 获取缓存的 menu key, 如果未获取到则使用 ROOTROUTE path 当作默认激活路由菜单 */ +export const getCatchMenuKey = () => { + const { path: rootPath } = ROOT_ROUTE + const cacheMenuKey = getStorage( + 'menuKey', + 'sessionStorage', + rootPath, + ) + + return cacheMenuKey +} diff --git a/src/store/modules/menu/index.ts b/src/store/modules/menu/index.ts new file mode 100644 index 00000000..30b642d9 --- /dev/null +++ b/src/store/modules/menu/index.ts @@ -0,0 +1,374 @@ +/** + * + * @author Ray + * + * @date 2022-11-03 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 该文件为 menu 菜单 pinia store + * + * 说明: + * - BreadcrumbMenu、TagMenu、Menu 统一管理 + * - BreadcrumbMenu、TagMenu、Menu 属性值重度依赖 vue-router routers, 所以需要按照该项目约定方法进行配置 + * + * 缓存(sessionStorage): + * - breadcrumbOptions + * - menuKey + */ + +import { NEllipsis } from 'naive-ui' + +import { setStorage } from '@/utils/cache' +import { validRole, validMenuItemShow } from '@/router/helper/routerCopilot' +import { + parseAndFindMatchingNodes, + updateDocumentTitle, + hasMenuIcon, + getCatchMenuKey, +} from './helper' +import { useI18n } from '@/locales/useI18n' +import { getAppRawRoutes } from '@/router/routeModules' +import { expandRoutes } from '@/router/helper/expandRoutes' +import { useKeepAlive } from '@/store' +import { useVueRouter } from '@/router/helper/useVueRouter' + +import type { MenuOption } from 'naive-ui' +import type { AppRouteMeta, AppRouteRecordRaw } from '@/router/type' +import type { + AppMenuOption, + MenuTagOptions, + AppMenuKey, +} from '@/types/modules/app' +import type { MenuState } from '@/store/modules/menu/type' + +export const useMenu = defineStore( + 'menu', + () => { + const { router } = useVueRouter() + const route = useRoute() + const { t } = useI18n() + const { setKeepAliveInclude } = useKeepAlive() + + const menuState = reactive({ + menuKey: getCatchMenuKey(), // 当前菜单 `key` + options: [], // 菜单列表 + collapsed: false, // 是否折叠菜单 + menuTagOptions: [], // tag 标签菜单 + breadcrumbOptions: [], // 面包屑菜单 + }) + const isSetupAppMenuLock = ref(true) + + /** + * + * @param options menu options + * @param key target key + * + * @remark 获取完整菜单项 + */ + const getCompleteRoutePath = ( + options: AppMenuOption[], + key: string | number, + ) => { + const ops = parseAndFindMatchingNodes(options, 'key', key) + + return ops + } + + /** + * + * 设置面包屑 + * + * 如果识别到为平级模式, 则会自动追加一层面包屑 + */ + const setBreadcrumbOptions = ( + key: string | number, + option: AppMenuOption, + ) => { + const { meta } = option as unknown as AppRouteRecordRaw + + menuState.breadcrumbOptions = getCompleteRoutePath(menuState.options, key) + + if (meta.sameLevel) { + nextTick().then(() => { + const fd = menuState.breadcrumbOptions.find((curr) => { + return curr.path === option.path + }) + + if (!fd) { + menuState.breadcrumbOptions.push(option as unknown as AppMenuOption) + } + }) + } + } + + /** + * + * @param optins menu tag option(s) + * @param isAppend true: 追加操作(push), false: 覆盖操作 + */ + const setMenuTagOptions = ( + optins: MenuTagOptions | MenuTagOptions[], + isAppend = true, + ) => { + const isArray = Array.isArray(optins) + const arr = isArray ? [...optins] : [optins] + + isAppend + ? menuState.menuTagOptions.push(...arr) + : (menuState.menuTagOptions = arr) + } + + /** 当 url 地址发生变化触发 menuTagOptions 更新 */ + const setMenuTagOptionsWhenMenuValueChange = ( + key: string | number, + option: AppMenuOption, + ) => { + const tag = menuState.menuTagOptions.find((curr) => curr.path === key) + + if (!tag) { + menuState.menuTagOptions.push(option as MenuTagOptions) + } + } + + /** + * + * @param key 菜单更新后的 key + * @param option 菜单当前 option 项 + * + * @remark 修改 `menu key` 后的回调函数 + * @remark 修改后, 缓存当前选择 key 并且存储标签页与跳转页面(router push 操作) + */ + const changeMenuModelValue = ( + key: string | number, + option: AppMenuOption, + ) => { + const { meta, path } = option + + if (meta.windowOpen) { + window.open(meta.windowOpen) + } else { + /** + * + * key 以 `/` 开头, 则说明为根路由, 直接跳转 + * key 开头未匹配到 `/`, 则需要获取到完整路由后再进行跳转 + * + * 但是, 缓存 key 都以当前点击 key 为准 + */ + if (!String(key).startsWith('/')) { + /** 如果不是根路由, 则拼接完整路由并跳转 */ + const _path = getCompleteRoutePath(menuState.options, key) + .map((curr) => curr.key) + .join('/') + + router.push(_path) + } else { + /** 根路由直接跳转 */ + router.push(path) + } + + /** 检查是否为根路由 */ + const count = (path.match(new RegExp('/', 'g')) || []).length + + /** 更新缓存队列 */ + setKeepAliveInclude(option as unknown as AppMenuOption) + /** 更新浏览器标题 */ + updateDocumentTitle(option as unknown as AppMenuOption) + + if (!meta.sameLevel || (meta.sameLevel && count === 1)) { + /** 更新标签菜单 */ + setMenuTagOptionsWhenMenuValueChange(key, option) + /** 更新面包屑 */ + setBreadcrumbOptions(key, option) + + menuState.menuKey = key + /** 缓存菜单 key(sessionStorage) */ + setStorage('menuKey', key) + } else { + setBreadcrumbOptions(menuState.menuKey || '', option) + } + } + } + + /** + * + * @param path 路由地址 + * + * @remark 监听路由地址变化更新菜单状态 + * @remark 递归查找匹配项 + */ + const updateMenuKeyWhenRouteUpdate = async (path: string) => { + // 获取 `/` 出现次数(如果为 1 则表示该路径为根路由路径) + const count = (path.match(new RegExp('/', 'g')) || []).length + let combinePath = path + + if (count > 1) { + // 如果不是跟路径则取出最后一项字符 + const splitPath = path.split('/').filter((curr) => curr) + + combinePath = splitPath[splitPath.length - 1] + } + + const findMenuOption = (pathKey: string, options: AppMenuOption[]) => { + for (const curr of options) { + if (curr.children?.length) { + findMenuOption(pathKey, curr.children) + } + + if (pathKey === curr.key) { + changeMenuModelValue(pathKey, curr) + + break + } + } + } + + findMenuOption(combinePath, menuState.options) + } + + /** + * + * @remark 初始化菜单列表, 并且按照权限过滤 + * @remark 如果权限发生变动, 则会触发强制弹出页面并且重新登陆 + */ + const setupAppMenu = () => { + return new Promise((resolve) => { + const resolveOption = (option: AppMenuOption) => { + const { meta } = option + + /** 设置 label, i18nKey 优先级最高 */ + const label = computed(() => + meta?.i18nKey ? t(`${meta!.i18nKey}`) : meta?.noLocalTitle, + ) + /** 拼装菜单项 */ + const route = { + ...option, + key: option.path, + label: () => + h(NEllipsis, null, { + default: () => label.value, + }), + breadcrumbLabel: label.value, + /** 检查该菜单项是否展示 */ + } as AppMenuOption + /** 合并 icon */ + const attr: AppMenuOption = Object.assign({}, route, { + icon: hasMenuIcon(option), + }) + + if (option.path === getCatchMenuKey()) { + /** 设置标签页(初始化时执行设置一次, 避免含有平级路由模式情况时出现不能正确设置标签页的情况) */ + setMenuTagOptionsWhenMenuValueChange(option.path, attr) + } + + attr.show = validMenuItemShow(attr) + + return attr + } + + const resolveRoutes = (routes: AppMenuOption[], index: number) => { + const catchArr: AppMenuOption[] = [] + + for (const curr of routes) { + if (curr.children?.length) { + curr.children = resolveRoutes(curr.children, index++) + } else if (!validRole(curr.meta)) { + /** 如果校验失败, 则不会添加进 menu options */ + continue + } + + catchArr.push(resolveOption(curr)) + } + + return catchArr + } + + /** 缓存菜单列表 */ + menuState.options = resolveRoutes( + getAppRawRoutes() as AppMenuOption[], + 0, + ) + + resolve() + }) + } + + /** + * + * @param collapsed 折叠菜单开关 + */ + const collapsedMenu = (collapsed: boolean) => + (menuState.collapsed = collapsed) + + /** + * + * @param idx 当前关闭标签索引 + * @param length 裁剪标签页长度 + * + * @returns 被关闭标签项 + */ + const spliceMenTagOptions = (idx: number, length = 1) => + menuState.menuTagOptions.splice(idx, length) + + /** + * + * @remark 置空 menuTagOptions + * + * Q: 为什么不直接使用 spliceMenTagOptions 方法置空菜单标签? + * A: 因为直接将 menuTagOptions 指向新的地址会快一点 + */ + const emptyMenuTagOptions = () => { + menuState.menuTagOptions = [] + } + + /** + * + * 初始化系统菜单列表 + * 该方法仅执行一次 + */ + const setupPiniaMenuStore = async () => { + if (isSetupAppMenuLock.value) { + await setupAppMenu() + } + + isSetupAppMenuLock.value = false + } + + /** 监听路由变化并且更新路由菜单与菜单标签 */ + watch( + () => route.fullPath, + async (newData) => { + const reg = /^([^?]+)/ + const match = newData.match(reg)?.[1] + + await setupPiniaMenuStore() + await updateMenuKeyWhenRouteUpdate(match || '') + }, + { + immediate: true, + }, + ) + + return { + ...toRefs(menuState), + changeMenuModelValue, + setupAppMenu, + collapsedMenu, + spliceMenTagOptions, + emptyMenuTagOptions, + setMenuTagOptions, + } + }, + { + persist: { + key: 'piniaMenuStore', + storage: window.sessionStorage, + paths: ['breadcrumbOptions', 'menuKey'], + }, + }, +) diff --git a/src/store/modules/menu/type.ts b/src/store/modules/menu/type.ts new file mode 100644 index 00000000..c1cc0cfc --- /dev/null +++ b/src/store/modules/menu/type.ts @@ -0,0 +1,13 @@ +import type { + AppMenuOption, + MenuTagOptions, + AppMenuKey, +} from '@/types/modules/app' + +export interface MenuState { + menuKey: AppMenuKey + options: AppMenuOption[] + collapsed: boolean + menuTagOptions: MenuTagOptions[] + breadcrumbOptions: AppMenuOption[] +} diff --git a/src/store/modules/setting/index.ts b/src/store/modules/setting/index.ts new file mode 100644 index 00000000..9b362438 --- /dev/null +++ b/src/store/modules/setting/index.ts @@ -0,0 +1,112 @@ +import { getAppDefaultLanguage } from '@/locales/helper' +import { setStorage } from '@use-utils/cache' +import { set } from 'lodash-es' +import { addClass, removeClass, colorToRgba } from '@/utils/element' +import { useI18n } from '@/locales/useI18n' +import { APP_THEME } from '@/appConfig/designConfig' +import { useDayjs } from '@/dayjs/index' + +import type { ConditionalPick } from '@/types/modules/helper' +import type { SettingState } from '@/store/modules/setting/type' +import type { DayjsLocal } from '@/dayjs/type' + +export const useSetting = defineStore( + 'setting', + () => { + const { + appPrimaryColor: { primaryColor }, + } = __APP_CFG__ // 默认主题色 + const { locale } = useI18n() + const { locale: dayjsLocal } = useDayjs() + + const settingState = reactive({ + drawerPlacement: 'right', + primaryColorOverride: { + ...APP_THEME.APP_NAIVE_UI_THEME_OVERRIDES, + common: { + primaryColor: primaryColor, // 主题色 + primaryColorHover: primaryColor, + }, + }, + themeValue: false, // `true` 为黑夜主题, `false` 为白色主题 + reloadRouteSwitch: true, // 刷新路由开关 + menuTagSwitch: true, // 多标签页开关 + spinSwitch: false, // 全屏加载 + invertSwitch: false, // 反转色模式 + breadcrumbSwitch: true, // 面包屑开关 + localeLanguage: getAppDefaultLanguage(), + lockScreenSwitch: false, // 锁屏开关 + lockScreenInputSwitch: false, // 锁屏输入状态开关(预留该字段是为了方便拓展用, 但是舍弃了该字段, 改为使用 useAppLockScreen 方法) + }) + + /** 修改当前语言 */ + const updateLocale = (key: string) => { + locale(key) + dayjsLocal(key as DayjsLocal) + + settingState.localeLanguage = key + + setStorage('localeLanguage', key, 'localStorage') + } + + /** 切换主题色 */ + const changePrimaryColor = (value: string) => { + set( + settingState, + 'settingState.primaryColorOverride.common.primaryColorHover', + value, + ) + + const body = document.body + + /** 设置主题色变量 */ + body.style.setProperty('--ray-theme-primary-color', value) + body.style.setProperty( + '--ray-theme-primary-fade-color', + colorToRgba(value, 0.3), + ) + } + + /** + * + * @param bool 开关当前值 + * @param key `settingState` 对应开关属性值 + * + * @remark 仅适用于值为 `boolean` 的切换 + */ + const changeSwitcher = ( + bool: boolean, + key: keyof ConditionalPick, + ) => { + if ( + Object.hasOwn(settingState, key) && + typeof settingState[key] === 'boolean' + ) { + settingState[key] = bool + } + } + + /** 动态添加反转色 class name */ + watch( + () => settingState.invertSwitch, + (newData) => { + const body = document.body + const className = 'ray-template--invert' + + newData ? addClass(body, className) : removeClass(body, className) + }, + ) + + return { + ...toRefs(settingState), + updateLocale, + changePrimaryColor, + changeSwitcher, + } + }, + { + persist: { + key: 'piniaSettingStore', + }, + }, +) diff --git a/src/store/modules/setting/type.ts b/src/store/modules/setting/type.ts new file mode 100644 index 00000000..c6130880 --- /dev/null +++ b/src/store/modules/setting/type.ts @@ -0,0 +1,16 @@ +import type { GlobalThemeOverrides } from 'naive-ui' +import type { Placement } from '@/types/modules/component' + +export interface SettingState { + drawerPlacement: Placement + primaryColorOverride: GlobalThemeOverrides + themeValue: boolean + reloadRouteSwitch: boolean + menuTagSwitch: boolean + spinSwitch: boolean + breadcrumbSwitch: boolean + localeLanguage: string + invertSwitch: boolean + lockScreenSwitch: boolean + lockScreenInputSwitch: boolean +} diff --git a/src/store/modules/signin/index.ts b/src/store/modules/signin/index.ts new file mode 100644 index 00000000..134646b9 --- /dev/null +++ b/src/store/modules/signin/index.ts @@ -0,0 +1,100 @@ +/** + * + * @author Ray + * + * @date 2023-01-28 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 出于便捷性考虑, 将用户部分信息存于 pinia 仓库 + * 可以存储: 头像, 权限, 以及基于你项目实际情况的一些附带信息 + * + * 使用 sessionStorage 缓存部分用户信息 + * + * 默认仅缓存 signinCallback 属性 + */ + +import { isEmpty } from 'lodash-es' +import { removeStorage } from '@/utils/cache' + +import type { + SigninForm, + SigninCallback, + SigninResponse, +} from '@/store/modules/signin/type' + +export const useSignin = defineStore( + 'signin', + () => { + const state = reactive({ + /** + * + * 登陆返回信息(可以存放用户名、权限、头像等一些信息) + * 路由鉴权依赖该属性中的 role 属性, 如果需要更改请同步更改: router/basic.ts、router/permission.ts + */ + signinCallback: {} as SigninCallback, + }) + + /** + * + * @param signinForm 用户登录信息 + * @returns 状态码 + * + * @remark 0: 登陆成功, 1: 登陆失败 + */ + const signin = (signinForm: SigninForm): Promise => { + return new Promise((resolve, reject) => { + if (!isEmpty(signinForm)) { + state.signinCallback = { + role: 'admin', + name: signinForm.name, + avatar: + 'https://usc1.contabostorage.com/c2e495d7890844d392e8ec0c6e5d77eb:image/longmao.jpeg', + } + + resolve({ + code: 0, + message: '登陆成功', + data: state.signinCallback, + }) + } else { + reject({ + code: 1, + message: '登陆失败', + data: null, + }) + } + }) + } + + /** + * + * 退出登陆并且清空缓存数据 + * 延迟 300ms 后强制刷新当前系统 + */ + const logout = () => { + window.$message.info('账号退出中...') + removeStorage('all-sessionStorage') + + setTimeout(() => window.location.reload()) + } + + return { + ...toRefs(state), + signin, + logout, + } + }, + { + persist: { + key: 'piniaSigninStore', + paths: ['signinCallback'], + storage: sessionStorage, + }, + }, +) diff --git a/src/store/modules/signin/type.ts b/src/store/modules/signin/type.ts new file mode 100644 index 00000000..08ccd33f --- /dev/null +++ b/src/store/modules/signin/type.ts @@ -0,0 +1,16 @@ +export interface SigninForm extends UnknownObjectKey { + name: string + pwd: string +} + +export interface SigninCallback extends UnknownObjectKey { + role: string + name: string + avatar?: string +} + +export interface SigninResponse extends UnknownObjectKey { + code: number + data: SigninCallback + message: string +} diff --git a/src/styles/animate.scss b/src/styles/animate.scss new file mode 100644 index 00000000..c4590c20 --- /dev/null +++ b/src/styles/animate.scss @@ -0,0 +1,14 @@ +.fade-enter-active, +.fade-leave-active { + transition: all 0.35s; +} + +.fade-enter-from { + opacity: 0; + transform: translateX(-30px); +} + +.fade-leave-to { + opacity: 0; + transform: translateX(30px); +} diff --git a/src/styles/base.scss b/src/styles/base.scss new file mode 100644 index 00000000..4c135db3 --- /dev/null +++ b/src/styles/base.scss @@ -0,0 +1,62 @@ +@import "@/styles/animate.scss"; +@import "@/styles/root.scss"; +@import "@/styles/naive.scss"; + +body, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +p, +blockquote, +dl, +dt, +dd, +ul, +ol, +li, +pre, +form, +fieldset, +legend, +button, +input, +textarea, +th, +td { + margin: 0; + padding: 0; +} + +ul, +ol, +li { + list-style: none; +} + +fieldset, +img { + border: 0; + vertical-align: middle; +} + +body { + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", + "Droid Sans", "Helvetica Neue", sans-serif; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body.ray-template--invert { + filter: invert(1); +} + +body .ray-template__directive--disabled { + opacity: 0.3 !important; + pointer-events: none !important; + cursor: not-allowed !important; +} diff --git a/src/styles/mixins.scss b/src/styles/mixins.scss new file mode 100644 index 00000000..28aeffd1 --- /dev/null +++ b/src/styles/mixins.scss @@ -0,0 +1,49 @@ +// 弹性盒子垂直居中 +@mixin flexCenter { + display: flex; + justify-content: center; + align-items: center; +} + +// 文字溢出变为: ...... +@mixin overflowEllipsis { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +// 滚动条样式 +@mixin scrollStyle { + ::-webkit-scrollbar { + // 改变纵向滚动条宽度 + width: 5px; + height: 5px; + transition: background-color 0.2s var(--r-scrollbar-bezier); + cursor: pointer; + } + + ::-webkit-scrollbar-track { + // 改变滚动条轨道颜色 + border-radius: 5px; + background-color: transparent; + } + + ::-webkit-scrollbar-thumb { + // 改变滚动条滑轨相关的样式 + border-radius: 5px; + background-color: rgba(255, 255, 255, 0.2); + } + ::-webkit-scrollbar-thumb:hover { + // 移入鼠标效果 + border-radius: 5px; + background-color: rgba(255, 255, 255, 0.3); + cursor: pointer; + } +} + +// 根据主题切换样式 +@mixin useAppTheme($theme) { + body[class="ray-template--#{$theme}"] & { + @content; + } +} diff --git a/src/styles/naive.scss b/src/styles/naive.scss new file mode 100644 index 00000000..e3fcfd72 --- /dev/null +++ b/src/styles/naive.scss @@ -0,0 +1,5 @@ +.n-spin-container, +.n-spin-container .n-spin-content { + width: 100%; + height: 100%; +} diff --git a/src/styles/root.scss b/src/styles/root.scss new file mode 100644 index 00000000..9a93bae0 --- /dev/null +++ b/src/styles/root.scss @@ -0,0 +1,3 @@ +:root { + --r-bezier: cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/src/styles/setting.scss b/src/styles/setting.scss new file mode 100644 index 00000000..e6146e3d --- /dev/null +++ b/src/styles/setting.scss @@ -0,0 +1,3 @@ +$layoutRouterViewContainer: 18px; +$layoutHeaderHeight: 64px; +$layoutMenuHeight: 46px; diff --git a/src/types/app.d.ts b/src/types/app.d.ts new file mode 100644 index 00000000..c1f5d456 --- /dev/null +++ b/src/types/app.d.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import 'vue-router' + +import type { AppRouteMeta } from '@/router/type' + +declare module 'vue-router' { + interface RouteMeta extends AppRouteMeta {} +} + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const Component: DefineComponent<{}, {}, any> + export default Component +} + +declare module 'virtual:*' { + const result: any + export default result +} + +declare module '*.json' { + const jsonContent: Record + export default jsonContent +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 00000000..2ff174ea --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { AppConfig } from './modules/cfg' +import type { + MessageApi, + DialogApi, + LoadingBarApi, + NotificationApi, +} from 'naive-ui' + +export declare global { + declare interface UnknownObjectKey { + [propName: string]: any + } + + declare const __APP_CFG__: AppConfig + + declare interface Window { + // 是否存在无界 + __POWERED_BY_WUJIE__?: boolean + // 子应用公共加载路径 + __WUJIE_PUBLIC_PATH__: string + // 原生的 `querySelector` + __WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__: typeof Document.prototype.querySelector + // 原生的 `querySelectorAll` + __WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__: typeof Document.prototype.querySelectorAll + // 原生的 `window` 对象 + __WUJIE_RAW_WINDOW__: Window + // 子应用沙盒实例 + __WUJIE: WuJie + // 子应用mount函数 + __WUJIE_MOUNT: () => void + // 子应用unmount函数 + __WUJIE_UNMOUNT: () => void + // 注入对象 + $wujie: { + bus: EventBus + shadowRoot?: ShadowRoot + props?: { [key: string]: unknown } + location?: Object + } + + $message: MessageApi + $dialog: DialogApi + $loadingBar: LoadingBarApi + $notification: NotificationApi + + DocsAPI?: any + DocEditor?: any + + msCrypto: Crypto + } +} diff --git a/src/types/modules/app.ts b/src/types/modules/app.ts new file mode 100644 index 00000000..7d2122c3 --- /dev/null +++ b/src/types/modules/app.ts @@ -0,0 +1,21 @@ +import type { VNode } from 'vue' +import type { AppRouteRecordRaw, AppRouteMeta } from '@/router/type' + +export type Key = string | number + +export interface AppMenuOption extends AppRouteRecordRaw { + name: string + key: Key + path: string + label: string | (() => VNode) + show?: boolean + children?: AppMenuOption[] + meta: AppRouteMeta + breadcrumbLabel?: string +} + +export interface MenuTagOptions extends AppMenuOption { + closeable?: boolean +} + +export type AppMenuKey = Key | null diff --git a/src/types/modules/appConfig.ts b/src/types/modules/appConfig.ts new file mode 100644 index 00000000..d5226619 --- /dev/null +++ b/src/types/modules/appConfig.ts @@ -0,0 +1,19 @@ +import type { CreateAxiosDefaults } from 'axios' + +export type CollapsedMode = 'transform' | 'width' + +export interface AppMenuConfig { + MENU_COLLAPSED_WIDTH: number + MENU_COLLAPSED_MODE: CollapsedMode + MENU_COLLAPSED_ICON_SIZE: number + MENU_COLLAPSED_INDENT: number + MENU_ACCORDION: boolean +} + +export interface AppKeepAlive { + setupKeepAlive: boolean + keepAliveExclude?: string[] + maxKeepAliveLength: number +} + +export interface AxiosConfig extends Omit {} diff --git a/src/types/modules/axios.ts b/src/types/modules/axios.ts new file mode 100644 index 00000000..ad25f258 --- /dev/null +++ b/src/types/modules/axios.ts @@ -0,0 +1,5 @@ +export interface AxiosResponseBody { + data: T + message: string + code: number +} diff --git a/src/types/modules/cfg.ts b/src/types/modules/cfg.ts new file mode 100644 index 00000000..aadb2165 --- /dev/null +++ b/src/types/modules/cfg.ts @@ -0,0 +1,82 @@ +import type { VNodeChild } from 'vue' +import type { + ServerOptions, + BuildOptions, + AliasOptions, + UserConfigExport, +} from 'vite' +import type { Recordable } from '@/types/modules/helper' +import type { GlobalThemeOverrides } from 'naive-ui' + +export interface LayoutSideBarLogo { + icon?: string + title?: string + url?: string + jumpType?: 'station' | 'outsideStation' +} + +export type LayoutCopyright = string | number | VNodeChild + +export interface RootRoute { + name: string + path: string +} + +export interface HTMLTitle { + name: string + transformIndexHtml: (title: string) => string +} + +export interface PreloadingConfig { + title?: string + tagColor?: string + titleColor?: string +} + +export interface AppPrimaryColor { + primaryColor: string + primaryFadeColor: string +} + +export interface Config { + server: ServerOptions + buildOptions: (mode: string) => BuildOptions + alias: AliasOptions + title: HTMLTitle + copyright?: LayoutCopyright + sideBarLogo?: LayoutSideBarLogo + mixinCSS?: string + preloadingConfig?: PreloadingConfig + base?: string + appPrimaryColor?: AppPrimaryColor +} + +/** + * + * 全局注入配置 + * + * 使用示例: + * const { layout } = __APP_CFG__ + */ +export interface AppConfig { + pkg: { + name: string + version: string + dependencies: Recordable + devDependencies: Recordable + } + layout: { + copyright?: LayoutCopyright + sideBarLogo?: LayoutSideBarLogo + } + base?: string + appPrimaryColor: AppPrimaryColor +} + +export type AppConfigExport = Config & UserConfigExport + +export interface AppTheme { + APP_THEME_COLOR: string[] + APP_PRIMARY_COLOR: AppPrimaryColor + APP_NAIVE_UI_THEME_OVERRIDES: GlobalThemeOverrides +} diff --git a/src/types/modules/component.ts b/src/types/modules/component.ts new file mode 100644 index 00000000..e9fc9b12 --- /dev/null +++ b/src/types/modules/component.ts @@ -0,0 +1,10 @@ +import type { ECharts } from 'echarts/core' +import type { MenuOption, MenuDividerOption, MenuGroupOption } from 'naive-ui' + +export type ComponentSize = 'small' | 'medium' | 'large' + +export type EChartsInstance = ECharts + +export type Placement = 'top' | 'right' | 'bottom' | 'left' + +export type NaiveMenuOptions = MenuOption | MenuDividerOption | MenuGroupOption diff --git a/src/types/modules/helper.ts b/src/types/modules/helper.ts new file mode 100644 index 00000000..144bd302 --- /dev/null +++ b/src/types/modules/helper.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type ConditionalKeys = NonNullable< + { + [Key in keyof Base]: Base[Key] extends Condition ? Key : never + }[keyof Base] +> + +export type ConditionalPick = Pick< + Base, + ConditionalKeys +> + +export type Recordable = Record diff --git a/src/types/modules/utils.ts b/src/types/modules/utils.ts new file mode 100644 index 00000000..e2612915 --- /dev/null +++ b/src/types/modules/utils.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type CryptoJS from 'crypto-js' + +export type CacheType = 'sessionStorage' | 'localStorage' + +export type EventListenerOrEventListenerObject = + | EventListener + | EventListenerObject + +export type ValidteValueType = + | 'Object' + | 'Undefined' + | 'Null' + | 'Boolean' + | 'Number' + | 'String' + | 'Symbol' + | 'Function' + | 'Date' + | 'Array' + | 'RegExp' + | 'Map' + | 'Set' + | 'WeakMap' + | 'WeakSet' + | 'ArrayBuffer' + | 'DataView' + | 'Int8Array' + | 'Uint8Array' + | 'Uint8ClampedArray' + | 'Int16Array' + | 'Uint16Array' + | 'Int32Array' + | 'Uint32Array' + | 'Float32Array' + | 'Float64Array' + +export type WordArray = CryptoJS.lib.WordArray + +export type CipherParams = CryptoJS.lib.CipherParams + +export type AnyFunc = (...args: any[]) => any + +export type AnyVoidFunc = (...args: any[]) => void + +export type PartialCSSStyleDeclaration = Partial< + Record +> + +export type ElementSelector = string | `attr:${string}` diff --git a/src/utils/cache.ts b/src/utils/cache.ts new file mode 100644 index 00000000..e929fcd7 --- /dev/null +++ b/src/utils/cache.ts @@ -0,0 +1,126 @@ +/** + * + * @author Ray + * + * @date 2023-06-05 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** vue3 项目里建议直接用 vueuse useStorage 方法 */ + +import type { CacheType } from '@/types/modules/utils' + +/** + * + * @param key 需要设置的key + * @param value 需要缓存的值 + */ +export function setStorage( + key: string, + value: T, + type: CacheType = 'sessionStorage', +) { + if (!key) { + console.error('Failed to set stored data: key is empty or undefined') + + return + } + + try { + const waitCacheValue = JSON.stringify(value) + + type === 'localStorage' + ? window.localStorage.setItem(key, waitCacheValue) + : window.sessionStorage.setItem(key, waitCacheValue) + } catch (error) { + console.error(`Failed to set stored data for key '${key}'`, error) + } +} + +/** 重载函数 getStorage */ +export function getStorage( + key: string, + storageType: CacheType, + defaultValue: T, +): T + +/** 重载函数 getStorage */ +export function getStorage( + key: string, + storageType?: CacheType, + defaultValue?: T, +): T | null + +/** + * + * @param key 需要获取目标缓存的key + * @returns 获取缓存值 + */ +export function getStorage( + key: string, + storageType: CacheType = 'sessionStorage', + defaultValue?: T, +): T | null { + try { + const data = + storageType === 'localStorage' + ? window.localStorage.getItem(key) + : window.sessionStorage.getItem(key) + + if (data === null) { + return defaultValue ?? null + } + + return JSON.parse(data) as T + } catch (error) { + console.error(`Failed to get stored data for key '${key}'`, error) + + return defaultValue ?? null + } +} + +/** + * + * @param key 需要删除的缓存值key + * + * key: + * - all: 删除所有缓存值 + * - all-sessionStorage: 删除所有 sessionStorage 缓存值 + * - all-localStorage: 删除所有 localStorage 缓存值 + */ +export function removeStorage( + key: string | 'all' | 'all-sessionStorage' | 'all-localStorage', + type: CacheType = 'sessionStorage', +) { + switch (key) { + case 'all': + window.window.localStorage.clear() + window.sessionStorage.clear() + + break + + case 'all-sessionStorage': + window.sessionStorage.clear() + + break + + case 'all-localStorage': + window.localStorage.clear() + + break + + default: + if (!key) { + console.error('Failed to remove stored data: key is empty or undefined') + + return + } + + type === 'localStorage' + ? window.localStorage.removeItem(key) + : window.sessionStorage.removeItem(key) + } +} diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 00000000..3e1aa7e6 --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,15 @@ +// import HmacSHA256 from 'crypto-js/hmac-sha256' +// import SHA256 from 'crypto-js/sha256' +// import AES from 'crypto-js/aes' +// import MD5 from 'crypto-js/md5' +// import BASE64 from 'crypto-js/enc-base64' + +// import type { WordArray, CipherParams } from '@/types/modules/utils' + +/** + * + * 这个玩意儿实际使用意义不大, 建议自己参考官网 demo 使用 + * 我又不想删除, 所以留在这儿了 + * + * 手动补上官网地址: http://github.com/brix/crypto-js + */ diff --git a/src/utils/element.ts b/src/utils/element.ts new file mode 100644 index 00000000..5cc73641 --- /dev/null +++ b/src/utils/element.ts @@ -0,0 +1,291 @@ +import { isValueType } from '@use-utils/hook' +import { APP_REGEX } from '@/appConfig/regexConfig' + +import type { + EventListenerOrEventListenerObject, + PartialCSSStyleDeclaration, + ElementSelector, +} from '@/types/modules/utils' + +/** + * + * @param element Target element dom + * @param event 绑定事件类型 + * @param handler 事件触发方法 + * @param useCapture 是否冒泡 + * + * @remark 给元素绑定某个事件柄方法 + */ +export const on = ( + element: HTMLElement | Document | Window, + event: string, + handler: EventListenerOrEventListenerObject, + useCapture: boolean | AddEventListenerOptions = false, +) => { + if (element && event && handler) { + element.addEventListener(event, handler, useCapture) + } +} + +/** + * + * @param element Target element dom + * @param event 卸载事件类型 + * @param handler 所需卸载方法 + * @param useCapture 是否冒泡 + * + * @remark 卸载元素上某个事件柄方法 + */ +export const off = ( + element: HTMLElement | Document | Window, + event: string, + handler: EventListenerOrEventListenerObject, + useCapture: boolean | AddEventListenerOptions = false, +) => { + if (element && event && handler) { + element.removeEventListener(event, handler, useCapture) + } +} + +/** + * + * @param element Target element dom + * @param className 所需添加className,可: 'xxx xxx' | 'xxx' 格式添加(参考向元素绑定 css 语法) + * + * @remark 添加元素className(可: 'xxx xxx' | 'xxx'格式添加) + */ +export const addClass = (element: HTMLElement, className: string) => { + if (element) { + const classes = className.trim().split(' ') + + classes.forEach((item) => { + if (item) { + element.classList.add(item) + } + }) + } +} + +/** + * + * @param element Target element dom + * @param className 所需删除className,可: 'xxx xxx' | 'xxx' 格式删除(参考向元素绑定 css 语法) + * + * @remark 删除元素className(可: 'xxx xxx' | 'xxx'格式删除) + * @remark 如果输入值为 removeAllClass 则会删除该元素所有 class name + */ +export const removeClass = ( + element: HTMLElement, + className: string | 'removeAllClass', +) => { + if (element) { + if (className === 'removeAllClass') { + const classList = element.classList + + classList.forEach((curr) => classList.remove(curr)) + } else { + const classes = className.trim().split(' ') + + classes.forEach((item) => { + if (item) { + element.classList.remove(item) + } + }) + } + } +} + +/** + * + * @param element Target element dom + * @param className 查询元素是否含有此className,可: 'xxx xxx' | 'xxx' 格式查询(参考向元素绑定 css 语法) + * + * @returns 返回boolean + * + * @remark 元素是否含有某个className(可: 'xxx xxx' | 'xxx' 格式查询) + */ +export const hasClass = (element: HTMLElement, className: string) => { + const elementClassName = element.className + + const classes = className + .trim() + .split(' ') + .filter((item: string) => item !== '') + + return elementClassName.includes(classes.join(' ')) +} + +/** + * + * @param el Target element dom + * @param styles 所需绑定样式(如果为字符串, 则必须以分号结尾每个行内样式描述) + * + * + * @example + * style of string + * ``` + * const styles = 'width: 100px; height: 100px; background: red;' + * + * addStyle(styles) + * ``` + * style of object + * ``` + * const styles = { + * width: '100px', + * height: '100px', + * } + * + * addStyle(styles) + * ``` + */ +export const addStyle = ( + el: HTMLElement, + styles: PartialCSSStyleDeclaration | string, +) => { + if (!el) { + return + } + + let styleObj: PartialCSSStyleDeclaration + + if (isValueType(styles, 'String')) { + styleObj = styles.split(';').reduce((pre, curr) => { + const [key, value] = curr.split(':').map((s) => s.trim()) + + if (key && value) { + pre[key] = value + } + + return pre + }, {} as PartialCSSStyleDeclaration) + } else { + styleObj = styles + } + + Object.keys(styleObj).forEach((key) => { + const value = styleObj[key] + + if (key in el.style) { + el.style[key] = value + } + }) +} + +/** + * + * @param el Target element dom + * @param styles 所需卸载样式 + */ +export const removeStyle = ( + el: HTMLElement, + styles: (keyof CSSStyleDeclaration & string)[], +) => { + if (!el) { + return + } + + styles.forEach((curr) => { + el.style.removeProperty(curr) + }) +} + +/** + * + * @param color 颜色格式 + * @param alpha 透明度 + * @returns 转换后的 rgba 颜色值 + * + * @remark 将任意颜色值转为 rgba + */ +export const colorToRgba = (color: string, alpha = 1) => { + const hexPattern = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i + const rgbPattern = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/i + const rgbaPattern = + /^rgba\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3}),\s*(\d*(?:\.\d+)?)\)$/i + + let result: string + + if (hexPattern.test(color)) { + const hex = color.substring(1) + const rgb = [ + parseInt(hex.substring(0, 2), 16), + parseInt(hex.substring(2, 4), 16), + parseInt(hex.substring(4, 6), 16), + ] + + result = 'rgb(' + rgb.join(', ') + ')' + } else if (rgbPattern.test(color)) { + result = color + } else if (rgbaPattern.test(color)) { + result = color + } else { + result = color + } + + if (result && !result.startsWith('rgba')) { + result = result.replace('rgb', 'rgba').replace(')', `, ${alpha})`) + } + + return result +} + +/** + * + * @param element 需要匹配元素参数名称 + * @returns 匹配元素列表 + * + * @remark 使用 querySelectorAll 作为检索方法 + * @remark 如果希望按照 attribute 匹配, 仅需要 'attr:xxx'传递参数即可 + * + * 示例: + * + * class: + * const el = queryElements('.demo') + * id: + * const el = queryElements('#demo') + * attribute: + * const el = queryElements('attr:type=button') + * 或者可以这样写 + * const el = queryElements('attr:type') + */ +export const queryElements = ( + selector: ElementSelector, +) => { + if (!selector) { + return null + } + + const queryParam = selector.startsWith('attr:') + ? `[${selector.replace('attr:', '')}]` + : selector + + try { + const elements = Array.from(document.querySelectorAll(queryParam)) + + return elements + } catch (error) { + console.error(`Failed to get elements for selector '${selector}'`, error) + + return null + } +} + +/** + * + * @param size css size + * @param unit 自动填充 css 尺寸单位 + * + * @remark 自动补全尺寸 + */ +export const completeSize = (size: number | string, unit = 'px') => { + if (typeof size === 'number') { + return size.toString() + unit + } else if ( + isValueType(size, 'String') && + APP_REGEX.validerCSSUnit.test(size) + ) { + return size + } else { + return size + unit + } +} diff --git a/src/utils/hook.ts b/src/utils/hook.ts new file mode 100644 index 00000000..1cecf4ef --- /dev/null +++ b/src/utils/hook.ts @@ -0,0 +1,81 @@ +import type { ValidteValueType } from '@/types/modules/utils' + +/** + * + * @returns 获取当前项目环境 + */ +export const getAppEnvironment = () => { + const env = import.meta.env + + return env +} + +/** + * + * @param data 二进制流数据 + * + * @returns formate binary to base64 of the image + */ +export const arrayBufferToBase64Image = (data: ArrayBuffer): string | null => { + if (!data || data.byteLength) { + return null + } + + const base64 = + 'data:image/png;base64,' + + window.btoa( + new Uint8Array(data).reduce( + (data, byte) => data + String.fromCharCode(byte), + '', + ), + ) + + return base64 +} + +/** + * + * @param value 目标值 + * @param type 类型 + */ +export const isValueType = ( + value: unknown, + type: ValidteValueType, +): value is T => { + const valid = Object.prototype.toString.call(value) + + return valid.includes(type) +} + +/** + * + * @param length `uuid` 长度 + * @param radix `uuid` 基数 + * @returns `uuid` + */ +export const uuid = (length = 16, radix = 62) => { + // 定义可用的字符集,即 0-9, A-Z, a-z + const availableChars = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('') + // 定义存储随机字符串的数组 + const arr: string[] = [] + // 获取加密对象,兼容 IE11 + const cryptoObj = window.crypto || window.msCrypto + let i = 0 + + // 循环 length 次,生成随机字符,并添加到数组中 + for (i = 0; i < length; i++) { + // 生成一个随机数 + const randomValues = new Uint32Array(1) + + cryptoObj.getRandomValues(randomValues) + + // 根据随机数生成对应的字符,并添加到数组中 + const index = randomValues[0] % radix + + arr.push(availableChars[index]) + } + + // 将数组中的字符连接起来,返回最终的字符串 + return arr.join('') +} diff --git a/src/utils/precision.ts b/src/utils/precision.ts new file mode 100644 index 00000000..7162b67a --- /dev/null +++ b/src/utils/precision.ts @@ -0,0 +1,172 @@ +/** + * + * @author Ray + * + * @date 2023-06-07 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * 文档地址: + * + * Options 默认值 + * - symbol default: `$`(货币符号) + * - separator default: `,`(数字分隔符, demo: 1234.56 => '1,234.56') + * - decimal default: `.`(十进制分隔符, demo: 1.23 => '1.23') + * - precision default: `2`(精度保留位数) + * - pattern default: `!#`(!: 货币符号代替, #: 货币金额代替) + * - negativePattern default: `!#`(!: 货币符号代替, #: 货币金额代替) + * - format default: `null`(默认格式化方法替代, 看文档) + * - fromCents default: `false` + * - fromCents default: `false`(尊重精度选项) + * - errorOnInvalid default: `false`(传入 null undefined 直接抛出错误) + * - increment default: `null`(四舍五入增量值) + * - useVedic default: `false`(分组格式化值, demo: currency(1234567.89, { useVedic: true }).format() => '12,34,567.89') + */ + +import currency from 'currency.js' +import { cloneDeep } from 'lodash-es' + +import type { Options } from 'currency.js' +import type { AnyFunc } from '@/types/modules/utils' + +export type CurrencyArguments = string | number | currency + +export type OriginalValueType = 'string' | 'number' + +/** + * + * @param valueOptions 待计算参数列表 + * @param dividend 初始值 + * @param cb 回调方法 + * + * @remark 计算基础方法, 仅限于该处使用 + */ +const basic = ( + valueOptions: CurrencyArguments[], + dividend: CurrencyArguments, + cb: AnyFunc, +) => { + if (!valueOptions?.length) { + return 0 + } + + if (valueOptions.length === 1) { + return currency(valueOptions[0]) + } + + const result = valueOptions.reduce((pre, curr, idx, arr) => { + pre = cb?.(pre, curr, idx, arr) + + return pre + }, dividend) + + return result +} + +/** + * + * 格式化一个数据值, 并且返回其原始值 + * 默认以 number 格式返回 + * + * 如果需要格式化为其他格式(如: 货币单位、分组、分隔符等), 请使用 currency format 方法格式 + */ +export const format = ( + value: CurrencyArguments, + options?: Options, + type: OriginalValueType = 'number', +) => { + return type === 'number' + ? currency(value, options).value + : currency(value, options).toString() +} + +/** 加法 */ +export const add = (...args: CurrencyArguments[]) => { + return basic(args, 0, (pre, curr) => { + return currency(pre).add(curr) + }) +} + +/** 减法 */ +export const subtract = (...args: CurrencyArguments[]) => { + if (args.length === 2) { + const [one, two] = args + + return currency(one).subtract(two) + } + + const cloneDeepArgs = cloneDeep(args) + const dividend = cloneDeepArgs.shift() as CurrencyArguments + + if (!cloneDeepArgs.length) { + return dividend + } + + return basic(cloneDeepArgs, dividend, (pre, curr) => { + return currency(pre).subtract(curr) + }) +} + +/** 乘法 */ +export const multiply = (...args: CurrencyArguments[]) => { + return basic(args, 1, (pre, curr) => { + return currency(pre).multiply(curr) + }) +} + +/** 除法 */ +export const divide = (...args: CurrencyArguments[]) => { + if (args.length === 2) { + const [one, two] = args + + return currency(one).divide(two) + } + + const cloneDeepArgs = cloneDeep(args) + const dividend = cloneDeepArgs.shift() as CurrencyArguments + + if (!cloneDeepArgs.length) { + return dividend + } + + return basic(cloneDeepArgs, dividend, (pre, curr) => { + return currency(pre).divide(curr) + }) +} + +/** + * + * 平分(将一个数值平均分配到一个数组中) + * 如果值为 undefind null 会自动转换为 0 + * + * ``` + * distribute(0, 1) => [0] + * distribute(0, 3) => [0, 0, 0] + * ``` + */ +export const distribute = ( + value: CurrencyArguments, + length: number, + options?: Options, +) => { + if (length <= 1) { + return [value ? value : 0] + } else { + if (!value) { + return new Array(length).fill(0) + } + } + + const result = currency(value, options) + .distribute(length) + .map((curr) => { + return format(curr, options) + }) + + return result +} diff --git a/src/utils/xlsx.ts b/src/utils/xlsx.ts new file mode 100644 index 00000000..b7018e9a --- /dev/null +++ b/src/utils/xlsx.ts @@ -0,0 +1,104 @@ +/** + * + * @author Ray + * + * @date 2023-01-15 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { utils, writeFileXLSX } from 'xlsx' +import dayjs from 'dayjs' + +import type { DataTableBaseColumn } from 'naive-ui' +import type { Range, WorkSheet } from 'xlsx' + +export interface ExportExcelHeader extends DataTableBaseColumn {} + +export type RowData = Record + +export interface ExportXLSXConfig { + filename?: string +} + +/** + * + * @param columns table columns + * @returns 处理后的表头 + */ +const setupSheetHeader = (columns: ExportExcelHeader[]) => { + const header = columns.reduce((pre, curr) => { + pre[curr.key] = curr.title + + return pre + }, {} as ExportExcelHeader) + + return header +} + +/** + * + * @param range table range + * @param sheetData sheet data + * @param sheetHeader table header + * + * @remark 替换表头 + * @remark 由于暂未想到更好的方法, 如果有好的想法可以戳我 + */ +const transformSheetHeader = ( + range: Range, + sheetData: WorkSheet, + sheetHeader: ExportExcelHeader, +) => { + for (let c = range.s.c; c <= range.e.c; c++) { + const header = utils.encode_col(c) + '1' + + sheetData[header].v = sheetHeader[sheetData[header].v] + } +} + +/** + * + * @param dataSource 表格数据源 + * @param columns 表头 + * @param config xlsx 输出配置 + * + * @remark 导出数据为 xlsx + * @remark 如果不设置表头, 则会使用 dataSource 第一行数据为默认表头 + */ +export const exportFileToXLSX = async ( + dataSource: RowData[], + columns?: ExportExcelHeader[], + config: ExportXLSXConfig = {}, +) => { + await new Promise((resolve, reject) => { + if (Array.isArray(dataSource)) { + if (dataSource.length) { + const sheetHeader = setupSheetHeader(columns ?? []) // 获取所有列(设置为 `excel` 表头) + const sheetData = utils.json_to_sheet(dataSource) // 将所有数据转换为表格数据类型 + const workBook = utils.book_new() + const filename = config.filename + ? config.filename + '.xlsx' + : dayjs().format('YYYY-MM-DD') + '导出表格.xlsx' + + utils.book_append_sheet(workBook, sheetData, 'Data') + + const range = utils.decode_range(sheetData['!ref'] as string) // 获取所有单元格 + + if (columns?.length) { + transformSheetHeader(range, sheetData, sheetHeader) + } + + writeFileXLSX(workBook, filename) // 输出表格 + + resolve() + } else { + resolve() + } + } else { + reject() + } + }) +} diff --git a/src/views/axios/index.scss b/src/views/axios/index.scss new file mode 100644 index 00000000..5cac4b4e --- /dev/null +++ b/src/views/axios/index.scss @@ -0,0 +1,3 @@ +.axios-header__btn { + height: 64px; +} diff --git a/src/views/axios/index.tsx b/src/views/axios/index.tsx new file mode 100644 index 00000000..cf313123 --- /dev/null +++ b/src/views/axios/index.tsx @@ -0,0 +1,99 @@ +import './index.scss' +import { + NCard, + NLayout, + NDataTable, + NLayoutContent, + NLayoutHeader, + NSpace, + NInput, + NButton, +} from 'naive-ui' +import { onAxiosTest } from '@use-api/test' +import { isArray } from 'lodash-es' + +const Axios = defineComponent({ + name: 'RAxios', + setup() { + const state = reactive({ + weatherData: [] as UnknownObjectKey[], + inputCityValue: '', + }) + const columns = [ + { + title: '空气指数', + key: 'air', + }, + { + title: '风速', + key: 'win_meter', + }, + { + title: '能见度', + key: 'visibility', + }, + { + title: '天气情况', + key: 'wea_day', + }, + { + title: '提示', + key: 'air_tips', + }, + ] + + const handleInputCityValue = async (value: string) => { + try { + const cb = await onAxiosTest(value) + + state.weatherData = cb.data + } catch (e) { + window.$message.error('请求已被取消') + } + } + + onBeforeMount(async () => { + const cb = await onAxiosTest('成都') + + state.weatherData = cb.data + }) + + return { + ...toRefs(state), + columns, + handleInputCityValue, + } + }, + render() { + return ( + + + + 基于 axios 封装,能够自动取消连续请求,避免重复渲染造成问题 +

+ 打开控制台 => 网络 => 使用低速3g网络 => + 查看控制台被取消的请求 +

+
+
+ + + + + 搜索 + + + + + + +
+ ) + }, +}) + +export default Axios diff --git a/src/views/dashboard/index.scss b/src/views/dashboard/index.scss new file mode 100644 index 00000000..10428428 --- /dev/null +++ b/src/views/dashboard/index.scss @@ -0,0 +1,13 @@ +.dashboard-layout { + & .n-card { + margin-top: 18px; + + &:first-child { + margin-top: 0; + } + } + + & .dashboard-link { + text-decoration: none; + } +} diff --git a/src/views/dashboard/index.tsx b/src/views/dashboard/index.tsx new file mode 100644 index 00000000..dc795760 --- /dev/null +++ b/src/views/dashboard/index.tsx @@ -0,0 +1,141 @@ +import './index.scss' +import { + NCard, + NLayout, + NDescriptions, + NDescriptionsItem, + NTag, + NSpace, + NP, + NH6, +} from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' +import RayLink from '@/components/RayLink/index' + +const Dashboard = defineComponent({ + name: 'RDashboard', + setup() { + const coverLetterOptions = [ + { + label: '掌握搬砖框架', + des: () => ( + + Vue3.x + React + + ), + }, + { + label: '从事搬砖时长', + des: () => ( + + 练习时长两年半的小白前端搬砖师 + + ), + }, + { + label: '个人', + des: () => ( + + + 努力搬砖、努力摸鱼, 建设美丽家园 + + ), + span: 2, + }, + { + label: '补充说明', + des: () => ( + + 如果有希望补充的功能可以在 + + GitHub + + 提一个 Issues + + ), + span: 2, + }, + ] + const technologyTagOptions = [ + { + label: 'Vue3.x', + value: 'Vue3.x', + }, + { + label: 'Vite4.0', + value: 'Vite4.0', + }, + { + label: 'Pinia', + value: 'Pinia', + }, + { + label: 'TSX', + value: 'TSX', + }, + ] + + return { + coverLetterOptions, + technologyTagOptions, + } + }, + render() { + return ( + + + {{ + header: () => + h( + RayIcon, + { + name: 'ray', + size: '64', + }, + {}, + ), + default: () => '当你看见这个页面后, 就说明项目已经启动成功了~', + }} + + + + {this.coverLetterOptions.map((curr) => ( + + {curr.des()} + + ))} + + + + 技术栈 + + + {this.technologyTagOptions.map((curr) => ( + + {curr.label} + + ))} + + + 项目介绍 + + 预设了最佳构建体验的配置与常用搬砖工具。意在提供一个简洁、快速上手的模板。 + + + + + + + ) + }, +}) + +export default Dashboard diff --git a/src/views/directive/index.tsx b/src/views/directive/index.tsx new file mode 100644 index 00000000..620bfc03 --- /dev/null +++ b/src/views/directive/index.tsx @@ -0,0 +1,141 @@ +/** + * + * @author Ray + * + * @date 2023-06-24 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { + NSpace, + NCard, + NInput, + NInputGroup, + NButton, + NSwitch, + NForm, + NFormItem, +} from 'naive-ui' + +import type { ConditionalPick } from '@/types/modules/helper' + +const RDirective = defineComponent({ + name: 'RDirective', + setup() { + const state = reactive({ + copyValueOne: '我是待复制内容区域一', + copyValueTwo: '我是待复制内容区域二', + throttleBtnClickCount: 0, + debounceBtnClickCount: 0, + disabledValue: false, + }) + + const updateDemoValue = ( + key: keyof ConditionalPick, + ) => { + state[key]++ + } + + return { + ...toRefs(state), + updateDemoValue, + } + }, + render() { + return ( + + 该页面展示如何使用已封装好的指令 + + + + 复制 + + + + + + 复制 + + + + + + 点击执行 + +

我执行了{this.throttleBtnClickCount}次

+

该方法 1s 内仅会执行一次

+
+
+ + + + 点击执行 + +

我执行了{this.debounceBtnClickCount}次

+

该方法将延迟 1s 执行

+
+
+ + + + {{ + checked: () => '取消', + unchecked: () => '禁用', + }} + + +

+ 该指令会强制禁用(通过 css 层面)禁用元素交互。但是 naive ui + 组件提供了完整的 disabled + 属性,所以在组件库有禁用需求时,直接调用组件库 disabled + 属性即可。但是值得注意的是,该指令优先级最高,会覆盖组件 + disabled 属性。 +

+
+ +
+ + +
+
+ + +

我是可以被禁用的文本内容

+
+
+ + + + + + + + 按钮 + + + +
+
+
+ ) + }, +}) + +export default RDirective diff --git a/src/views/doc/index.tsx b/src/views/doc/index.tsx new file mode 100644 index 00000000..bfb8624b --- /dev/null +++ b/src/views/doc/index.tsx @@ -0,0 +1,20 @@ +/** + * + * @author Ray + * + * @date 2022-12-29 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +const RayTemplateDoc = defineComponent({ + name: 'RayTemplateDoc', + // setup() {}, + render() { + return
+ }, +}) + +export default RayTemplateDoc diff --git a/src/views/echart/index.scss b/src/views/echart/index.scss new file mode 100644 index 00000000..5b4631e9 --- /dev/null +++ b/src/views/echart/index.scss @@ -0,0 +1,17 @@ +.echart { + width: 100%; + height: 100%; + + & .n-card { + margin-top: 18px; + + &:first-child { + margin-top: 0; + } + } + + & .chart--container { + width: 100%; + height: 500px; + } +} diff --git a/src/views/echart/index.tsx b/src/views/echart/index.tsx new file mode 100644 index 00000000..c41c67be --- /dev/null +++ b/src/views/echart/index.tsx @@ -0,0 +1,269 @@ +import './index.scss' + +import { NCard, NSwitch, NSpace, NP, NH6, NH2, NH3 } from 'naive-ui' +import RayChart from '@/components/RayChart/index' + +import type { EChartsInstance } from '@/types/modules/component' + +const Echart = defineComponent({ + name: 'REchart', + setup() { + const baseChartRef = ref() + const chartLoading = ref(false) + const chartAria = ref(false) + const state = reactive({ + loading: false, + }) + + const baseOptions = { + legend: {}, + tooltip: {}, + xAxis: { + type: 'category', + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + }, + yAxis: { + type: 'value', + }, + series: [ + { + name: '日期', + data: [120, 200, 150, 80, 70, 110, 130], + type: 'bar', + showBackground: true, + backgroundStyle: { + color: 'rgba(180, 180, 180, 0.2)', + }, + }, + ], + } + const basePieOptions = { + title: { + text: 'Referer of a Website', + subtext: 'Fake Data', + left: 'center', + }, + tooltip: { + trigger: 'item', + }, + legend: { + orient: 'vertical', + left: 'left', + }, + series: [ + { + name: 'Access From', + type: 'pie', + radius: '50%', + data: [ + { value: 1048, name: 'Search Engine' }, + { value: 735, name: 'Direct' }, + { value: 580, name: 'Email' }, + { value: 484, name: 'Union Ads' }, + { value: 300, name: 'Video Ads' }, + ], + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)', + }, + }, + }, + ], + } + const baseLineOptions = { + title: { + text: 'Stacked Area Chart', + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + label: { + backgroundColor: '#6a7985', + }, + }, + }, + legend: { + data: ['Email', 'Union Ads', 'Video Ads', 'Direct', 'Search Engine'], + }, + toolbox: { + feature: { + saveAsImage: {}, + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: [ + { + type: 'category', + boundaryGap: false, + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + }, + ], + yAxis: [ + { + type: 'value', + }, + ], + series: [ + { + name: 'Email', + type: 'line', + stack: 'Total', + areaStyle: {}, + emphasis: { + focus: 'series', + }, + data: [120, 132, 101, 134, 90, 230, 210], + }, + { + name: 'Union Ads', + type: 'line', + stack: 'Total', + areaStyle: {}, + emphasis: { + focus: 'series', + }, + data: [220, 182, 191, 234, 290, 330, 310], + }, + { + name: 'Video Ads', + type: 'line', + stack: 'Total', + areaStyle: {}, + emphasis: { + focus: 'series', + }, + data: [150, 232, 201, 154, 190, 330, 410], + }, + { + name: 'Direct', + type: 'line', + stack: 'Total', + areaStyle: {}, + emphasis: { + focus: 'series', + }, + data: [320, 332, 301, 334, 390, 330, 320], + }, + { + name: 'Search Engine', + type: 'line', + stack: 'Total', + label: { + show: true, + position: 'top', + }, + areaStyle: {}, + emphasis: { + focus: 'series', + }, + data: [820, 932, 901, 934, 1290, 1330, 1320], + }, + ], + } + + const handleLoadingShow = (bool: boolean) => { + state.loading = bool + } + + const handleAriaShow = (bool: boolean) => { + chartAria.value = bool + } + + const handleChartRenderSuccess = (chart: EChartsInstance) => { + window.$notification.info({ + title: '可视化图渲染成功回调函数', + content: '可视化图渲染成功, 并且返回了当前可视化图实例', + duration: 5 * 1000, + }) + + console.log(baseChartRef.value, chart) + } + + return { + baseOptions, + baseChartRef, + chartLoading, + handleLoadingShow, + chartAria, + handleAriaShow, + handleChartRenderSuccess, + basePieOptions, + baseLineOptions, + ...toRefs(state), + } + }, + render() { + return ( +
+ RayChart 组件使用 + + 该组件会默认以 200*200 + 宽高进行填充。预设了常用的图、方法组件,如果不满足需求,需要用 use + 方法进行手动拓展。该组件实现了自动跟随模板主题切换功能,但是动态切换损耗较大,所以默认不启用 + + 基础使用 +
+ +
+ 渲染成功后运行回调函数 +
+ +
+ 能跟随主题切换的可视化图 +
+ +
+ 不跟随主题切换的暗色主题可视化图 +
+ +
+ 加载动画 + + {{ + checked: () => '隐藏加载动画', + unchecked: () => '显示加载动画', + }} + +
+ +
+ 贴画可视化图 + + {{ + checked: () => '隐藏贴花', + unchecked: () => '显示贴花', + }} + +
+ +
+
+ ) + }, +}) + +export default Echart diff --git a/src/views/iframe/index.tsx b/src/views/iframe/index.tsx new file mode 100644 index 00000000..f559044d --- /dev/null +++ b/src/views/iframe/index.tsx @@ -0,0 +1,50 @@ +/** + * + * @author Ray + * + * @date 2023-06-09 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * RayIframe 组件使用示例 + * + * 具体使用参考 props 代码注释 + * 做了简单的一个组件封装, 希望有用 + */ + +import { NCard, NSpace } from 'naive-ui' +import RayIframe from '@/components/RayIframe/index' + +const IframeDemo = defineComponent({ + name: 'IframeDemo', + setup() { + return {} + }, + render() { + return ( + + + + + + + + + ) + }, +}) + +export default IframeDemo diff --git a/src/views/login/components/QRCodeSignin/index.scss b/src/views/login/components/QRCodeSignin/index.scss new file mode 100644 index 00000000..24e08824 --- /dev/null +++ b/src/views/login/components/QRCodeSignin/index.scss @@ -0,0 +1,5 @@ +.qrcode-signin { + width: 100%; + height: 220px; + @include flexCenter; +} diff --git a/src/views/login/components/QRCodeSignin/index.tsx b/src/views/login/components/QRCodeSignin/index.tsx new file mode 100644 index 00000000..fa5025ab --- /dev/null +++ b/src/views/login/components/QRCodeSignin/index.tsx @@ -0,0 +1,43 @@ +/** + * + * @author Ray + * + * @date 2023-04-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import './index.scss' + +import QRCode from 'qrcode.vue' + +/** + * + * 二维码登陆 + * + * 可以根据业务需求自行更改 + */ + +const QRCodeSignin = defineComponent({ + name: 'QRCodeSignin', + setup() { + const qrcodeState = reactive({ + qrcodeValue: 'https://github.com/XiaoDaiGua-Ray/xiaodaigua-ray.github.io', + }) + + return { + ...toRefs(qrcodeState), + } + }, + render() { + return ( + + ) + }, +}) + +export default QRCodeSignin diff --git a/src/views/login/components/Register/index.tsx b/src/views/login/components/Register/index.tsx new file mode 100644 index 00000000..f519e4c3 --- /dev/null +++ b/src/views/login/components/Register/index.tsx @@ -0,0 +1,12 @@ +import { NResult } from 'naive-ui' + +const Register = defineComponent({ + name: 'RRegister', + render() { + return ( + + ) + }, +}) + +export default Register diff --git a/src/views/login/components/SSOSignin/index.scss b/src/views/login/components/SSOSignin/index.scss new file mode 100644 index 00000000..5dbc4960 --- /dev/null +++ b/src/views/login/components/SSOSignin/index.scss @@ -0,0 +1,5 @@ +.ray-template--light { + & .sso-signin { + color: #878787; + } +} diff --git a/src/views/login/components/SSOSignin/index.tsx b/src/views/login/components/SSOSignin/index.tsx new file mode 100644 index 00000000..3eb73f0c --- /dev/null +++ b/src/views/login/components/SSOSignin/index.tsx @@ -0,0 +1,83 @@ +/** + * + * @author Ray + * + * @date 2023-04-02 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +/** + * + * SSO 单点登录模块组件 + * + * 按照需求自行增减 + */ + +import './index.scss' + +import { NSpace, NPopover } from 'naive-ui' +import RayIcon from '@/components/RayIcon/index' + +interface SSOSigninOptions { + icon: string + key: string + tooltipLabel: string +} + +const SSOSignin = defineComponent({ + name: 'SSOSignin', + setup() { + const ssoSigninOptions = [ + { + icon: 'github', + key: 'github', + tooltipLabel: 'Github登陆', + }, + { + icon: 'google', + key: 'google', + tooltipLabel: 'Google登陆', + }, + { + icon: 'twitter', + key: 'twitter', + tooltipLabel: 'Twitter登陆', + }, + ] + + const handleSSOSigninClick = (option: SSOSigninOptions) => { + window.$message.info(`调用${option.tooltipLabel}`) + } + + return { + ssoSigninOptions, + handleSSOSigninClick, + } + }, + render() { + return ( + + ) + }, +}) + +export default SSOSignin diff --git a/src/views/login/components/Signin/index.tsx b/src/views/login/components/Signin/index.tsx new file mode 100644 index 00000000..8abc7615 --- /dev/null +++ b/src/views/login/components/Signin/index.tsx @@ -0,0 +1,110 @@ +import { NForm, NFormItem, NInput, NButton, NSpace, NDivider } from 'naive-ui' + +import { setStorage } from '@/utils/cache' +import { setSpin } from '@/spin' +import { useSignin } from '@/store' +import { useI18n } from '@/locales/useI18n' +import { APP_CATCH_KEY, ROOT_ROUTE } from '@/appConfig/appConfig' +import { useVueRouter } from '@/router/helper/useVueRouter' + +import type { FormInst } from 'naive-ui' + +const Signin = defineComponent({ + name: 'RSignin', + setup() { + const loginFormRef = ref() + + const { t } = useI18n() + const signinStore = useSignin() + + const { signin } = signinStore + const { path } = ROOT_ROUTE + + const useSigninForm = () => ({ + name: 'Ray Admin', + pwd: '123456', + }) + + const { router } = useVueRouter() + const signinForm = ref(useSigninForm()) + + const rules = { + name: { + required: true, + message: t('views.login.index.NamePlaceholder'), + trigger: ['blur', 'input'], + }, + pwd: { + required: true, + message: t('views.login.index.PasswordPlaceholder'), + trigger: ['blur', 'input'], + }, + } + + /** 普通登陆形式 */ + const handleLogin = () => { + loginFormRef.value?.validate((valid) => { + if (!valid) { + setSpin(true) + + signin(signinForm.value) + .then((res) => { + if (res.code === 0) { + setTimeout(() => { + setSpin(false) + + window.$message.success(`欢迎${signinForm.value.name}登陆~`) + + setStorage(APP_CATCH_KEY.token, 'tokenValue') + setStorage(APP_CATCH_KEY.signin, res.data) + + router.push(path) + }, 2 * 1000) + } + }) + .catch(() => { + window.$message.error('不可以这样哟, 不可以哟') + }) + } + }) + } + + return { + signinForm, + loginFormRef, + handleLogin, + rules, + t, + } + }, + render() { + const { t } = this + + return ( + + + + + + + + + {t('views.login.index.Login')} + + + ) + }, +}) + +export default Signin diff --git a/src/views/login/index.scss b/src/views/login/index.scss new file mode 100644 index 00000000..de4ea10c --- /dev/null +++ b/src/views/login/index.scss @@ -0,0 +1,114 @@ +$positionX: 24px; +$positionY: 24px; + +.login { + display: flex; + + & .login-wrapper { + position: relative; + flex: auto; + + &.login-wrapper--divider { + position: relative; + + &::before { + content: ""; + position: absolute; + width: var(--login-height); + height: 2px; + background: linear-gradient(135deg, transparent, transparent 75%, #2080f0, transparent 80%, transparent 100%), + linear-gradient(45deg, transparent, transparent 75%, #2080f0, transparent 80%, transparent 100%); + background-size: 1em 1em; + background-repeat: repeat-x, repeat-x; + transform: rotate(90deg); + transform-origin: 0; + left: 50%; + } + } + + & .login-title__wrapper { + position: absolute; + left: $positionX; + top: $positionY; + } + + & .login-action__wrapper { + position: absolute; + height: 54.4px; + right: $positionX; + top: $positionY; + } + + & .login-copyright__wrapper { + position: absolute; + width: 100%; + text-align: center; + bottom: $positionY; + font-size: 14px; + } + + & .login-wrapper__content { + width: 100%; + height: 100%; + + & .n-grid { + height: 100%; + } + + & .login__left-wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + transition: background-color 0.3s var(--r-bezier); + } + + & .login__right-wrapper { + @include flexCenter; + + & .login__right-wrapper__content { + width: 50%; + background-color: transparent; + } + } + } + } +} + +.ray-template--light { + & .login__left-wrapper { + background-color: rgba(32, 128, 240, 0.22); + } + + & .login__right-wrapper { + background-color: #ffffff; + } +} + +.ray-template--dark { + & .login__left-wrapper { + background-color: #2c354b; + } + + & .login__right-wrapper { + background-color: #2a3146; + } +} + +@media screen and (max-width: 1200px) { + .login__left-wrapper { + display: none !important; + } +} + +@media screen and (min-width: 768px) and (max-width: 992px) { + .login__right-wrapper .login__right-wrapper__content { + width: 55% !important; + } +} + +@media screen and (max-width: 768px) { + .login__right-wrapper .login__right-wrapper__content { + width: 100% !important; + } +} diff --git a/src/views/login/index.tsx b/src/views/login/index.tsx new file mode 100644 index 00000000..f6bbd3a4 --- /dev/null +++ b/src/views/login/index.tsx @@ -0,0 +1,169 @@ +import './index.scss' + +import { + NSpace, + NCard, + NTabs, + NTabPane, + NGradientText, + NDropdown, + NDivider, + NGrid, + NGridItem, +} from 'naive-ui' +import Signin from './components/Signin/index' +import Register from './components/Register/index' +import QRCodeSignin from './components/QRCodeSignin/index' +import SSOSignin from './components/SSOSignin/index' +import RayIcon from '@/components/RayIcon' +import RayLink from '@/components/RayLink/index' +import ThemeSwitch from '@/layout/components/SiderBar/components/SettingDrawer/components/ThemeSwitch/index' + +import { useSetting } from '@/store' +import { LOCAL_OPTIONS } from '@/appConfig/localConfig' +import { useI18n } from '@/locales/useI18n' + +const Login = defineComponent({ + name: 'RLogin', + setup() { + const { t } = useI18n() + const { + layout: { copyright }, + } = __APP_CFG__ + + const state = reactive({ + tabsValue: 'signin', + }) + + const { height: windowHeight, width: windowWidth } = useWindowSize() + const settingStore = useSetting() + const { updateLocale } = settingStore + + return { + ...toRefs(state), + windowHeight, + updateLocale, + t, + copyright, + windowWidth, + } + }, + render() { + const { t } = this + + return ( +
+
= 1440 ? 'login-wrapper--divider' : '', + ]} + > +
+ + + + + + + +
+
+
+ ) + }, +}) + +export default Login diff --git a/src/views/multi/views/multi-menu-one/index.tsx b/src/views/multi/views/multi-menu-one/index.tsx new file mode 100644 index 00000000..f4f6983d --- /dev/null +++ b/src/views/multi/views/multi-menu-one/index.tsx @@ -0,0 +1,33 @@ +/** + * + * @author Ray + * + * @date 2023-03-01 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { NInput } from 'naive-ui' + +const MultiMenuOne = defineComponent({ + name: 'MultiMenuOne', + setup() { + const inputValue = ref(null) + + return { + inputValue, + } + }, + render() { + return ( +
+ 多级菜单-1 + +
+ ) + }, +}) + +export default MultiMenuOne diff --git a/src/views/multi/views/multi-menu-two/views/sub-menu-other/index.tsx b/src/views/multi/views/multi-menu-two/views/sub-menu-other/index.tsx new file mode 100644 index 00000000..7fdd09d6 --- /dev/null +++ b/src/views/multi/views/multi-menu-two/views/sub-menu-other/index.tsx @@ -0,0 +1,33 @@ +/** + * + * @author Ray + * + * @date 2023-03-01 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { NInput } from 'naive-ui' + +const SubMenuOther = defineComponent({ + name: 'SubMenuOther', + setup() { + const inputValue = ref(null) + + return { + inputValue, + } + }, + render() { + return ( +
+ 多级菜单-2-1 + +
+ ) + }, +}) + +export default SubMenuOther diff --git a/src/views/multi/views/multi-menu-two/views/sub-menu/views/multi-menu-two-one/index.tsx b/src/views/multi/views/multi-menu-two/views/sub-menu/views/multi-menu-two-one/index.tsx new file mode 100644 index 00000000..d41f17ea --- /dev/null +++ b/src/views/multi/views/multi-menu-two/views/sub-menu/views/multi-menu-two-one/index.tsx @@ -0,0 +1,33 @@ +/** + * + * @author Ray + * + * @date 2023-03-01 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { NInput } from 'naive-ui' + +const MultiMenuTwoOne = defineComponent({ + name: 'MultiMenuTwoOne', + setup() { + const inputValue = ref(null) + + return { + inputValue, + } + }, + render() { + return ( +
+ 多级菜单2-2-1 + +
+ ) + }, +}) + +export default MultiMenuTwoOne diff --git a/src/views/office/index.tsx b/src/views/office/index.tsx new file mode 100644 index 00000000..c7df6024 --- /dev/null +++ b/src/views/office/index.tsx @@ -0,0 +1,21 @@ +/** + * + * @author Ray + * + * @date 2023-03-22 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { RouterView } from 'vue-router' + +const Office = defineComponent({ + name: 'ROffice', + render() { + return + }, +}) + +export default Office diff --git a/src/views/office/views/document/index.tsx b/src/views/office/views/document/index.tsx new file mode 100644 index 00000000..f2907266 --- /dev/null +++ b/src/views/office/views/document/index.tsx @@ -0,0 +1,47 @@ +/** + * + * @author Ray + * + * @date 2023-03-22 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { uuid } from '@/utils/hook' + +import type { PropType } from 'vue' + +const Document = defineComponent({ + name: 'RDocument', + setup() { + const editorUUID = uuid(16) + const state = reactive({}) + const config = { + document: { + fileType: 'docx', + key: editorUUID, + title: 'Example Document Title.docx', + url: 'https://example.com/url-to-example-document.docx', + }, + documentType: 'word', + authorization: 'a2122252', + token: 'a2122252', + Authorization: 'a2122252', + editorConfig: { + lang: 'zh-cn', + }, + } + + return { + ...toRefs(state), + editorUUID, + } + }, + render() { + return
+ }, +}) + +export default Document diff --git a/src/views/office/views/presentation/index.tsx b/src/views/office/views/presentation/index.tsx new file mode 100644 index 00000000..797a39dd --- /dev/null +++ b/src/views/office/views/presentation/index.tsx @@ -0,0 +1,24 @@ +/** + * + * @author Ray + * + * @date 2023-03-22 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import type { PropType } from 'vue' + +const Presentation = defineComponent({ + name: 'RPresentation', + setup() { + return {} + }, + render() { + return
+ }, +}) + +export default Presentation diff --git a/src/views/office/views/spreadsheet/index.tsx b/src/views/office/views/spreadsheet/index.tsx new file mode 100644 index 00000000..14bf9951 --- /dev/null +++ b/src/views/office/views/spreadsheet/index.tsx @@ -0,0 +1,24 @@ +/** + * + * @author Ray + * + * @date 2023-03-22 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import type { PropType } from 'vue' + +const Spreadsheet = defineComponent({ + name: 'RSpreadsheet', + setup() { + return {} + }, + render() { + return
+ }, +}) + +export default Spreadsheet diff --git a/src/views/precision/index.tsx b/src/views/precision/index.tsx new file mode 100644 index 00000000..250a1cc5 --- /dev/null +++ b/src/views/precision/index.tsx @@ -0,0 +1,166 @@ +/** + * + * @author Ray + * + * @date 2023-06-07 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { NLayout, NCard, NDynamicTags, NSpace, NInputNumber } from 'naive-ui' + +import { + add, + subtract, + multiply, + divide, + distribute, + format, +} from '@use-utils/precision' + +const CalculatePrecision = defineComponent({ + name: 'CalculatePrecision', + setup() { + const state = reactive({ + addOptions: ['1', '0.2', '0.1', '1.1'], + subtractOptions: ['1', '0.2', '0.1', '1.1'], + multiplyOptions: ['1', '0.2', '0.1', '1.1'], + divideOptions: ['1', '0.2', '0.1', '1.1'], + distributeValue: 12, + distributeOutputValue: [] as unknown[], + distributeLength: 3, + }) + const actionMap = { + addOptions: add, + subtractOptions: subtract, + multiplyOptions: multiply, + divideOptions: divide, + } + + const copilotFunc = (value: string[], path: string) => { + const action = actionMap[path] + const result = action(...value) + + return '结果: ' + format(result) + } + + const updateDistributeValue = () => { + nextTick().then(() => { + state.distributeOutputValue = distribute( + state.distributeValue, + state.distributeLength, + ) + }) + } + updateDistributeValue() + + return { + ...toRefs(state), + copilotFunc, + updateDistributeValue, + } + }, + render() { + return ( + +

+ 计算方法,默认都保留两位小数与四舍五入,可以根据 format 方法自行转换 +

+

+ 示例方法都基于 currency.js + 封装,利用其精度处理能力封装了常用的一些计算方法,解决精度问题。如果需要其他的方法请阅读官方文档 + https://currency.js.org/#subtract +

+ + + {{ + default: () => ( + { + this.copilotFunc(value, 'addOptions') + }} + /> + ), + footer: () => { + return this.copilotFunc(this.addOptions, 'addOptions') + }, + }} + + + {{ + default: () => ( + { + this.copilotFunc(value, 'subtractOptions') + }} + /> + ), + footer: () => { + return this.copilotFunc(this.subtractOptions, 'subtractOptions') + }, + }} + + + {{ + default: () => ( + { + this.copilotFunc(value, 'multiplyOptions') + }} + /> + ), + footer: () => { + return this.copilotFunc(this.multiplyOptions, 'multiplyOptions') + }, + }} + + + {{ + default: () => ( + { + this.copilotFunc(value, 'divideOptions') + }} + /> + ), + footer: () => { + return this.copilotFunc(this.divideOptions, 'divideOptions') + }, + }} + + + {{ + default: () => ( + + { + this.updateDistributeValue() + }} + /> + { + this.updateDistributeValue() + }} + /> + + ), + footer: () => { + return '结果: ' + this.distributeOutputValue.join(', ') + }, + }} + + +
+ ) + }, +}) + +export default CalculatePrecision diff --git a/src/views/rely/views/rely-about/index.scss b/src/views/rely/views/rely-about/index.scss new file mode 100644 index 00000000..cd64631a --- /dev/null +++ b/src/views/rely/views/rely-about/index.scss @@ -0,0 +1,9 @@ +.rely-about { + & .n-card { + margin-top: 18px; + + &:first-child { + margin-top: 0; + } + } +} diff --git a/src/views/rely/views/rely-about/index.tsx b/src/views/rely/views/rely-about/index.tsx new file mode 100644 index 00000000..c272d08b --- /dev/null +++ b/src/views/rely/views/rely-about/index.tsx @@ -0,0 +1,139 @@ +import './index.scss' +import { NCard, NDescriptions, NDescriptionsItem, NTag } from 'naive-ui' + +interface RelyDataOptions { + name: string + relyVersion: string + relyAddress: string +} + +interface TemplateOptions { + name: string + label: string + url?: string +} + +const RelyAbout = defineComponent({ + name: 'RelyAbout', + setup() { + const { pkg } = __APP_CFG__ + const { dependencies, devDependencies, name, version } = pkg + + const columns = [ + { + title: '依赖名称', + key: 'name', + }, + { + title: '依赖版本', + key: 'relyVersion', + }, + { + title: '依赖地址', + key: 'relyAddress', + }, + ] + const dependenciesOptions = ref([]) + const devDependenciesOptions = ref([]) + + const templateOptions = [ + { + name: '项目名称', + label: name, + }, + { + name: '版本信息', + label: version, + }, + { + name: '项目地址', + label: 'GitHub', + url: 'https://github.com/XiaoDaiGua-Ray/ray-template', + }, + ] + + const handleGetRelyData = () => { + const _arrayFrom = (obj: object) => + Object.keys(obj).reduce((pre, curr) => { + pre.push({ + name: curr, + relyVersion: obj[curr], + relyAddress: '', + }) + + return pre + }, [] as RelyDataOptions[]) + + dependenciesOptions.value = _arrayFrom(dependencies) + devDependenciesOptions.value = _arrayFrom(devDependencies) + } + + const handleTagClick = (item: TemplateOptions) => { + if (item.url) { + window.open(item.url) + } + } + + onBeforeMount(() => { + handleGetRelyData() + }) + + return { + columns, + dependenciesOptions, + devDependenciesOptions, + templateOptions, + handleTagClick, + } + }, + render() { + return ( +
+ + ray template 是一个基于: tsx pinia vue3.x vite sass 的中后台解决方案. + 项目干净与轻巧, + 已经集成了很多项目中可能需要的搬砖工具可以让你快速起一个相关项目, + 并且不需要剔除大量无用页面与组件. + + + + {this.templateOptions.map((curr) => ( + + + {curr.label} + + + ))} + + + + + {this.dependenciesOptions.map((curr) => ( + + {curr.relyVersion} + + ))} + + + + + {this.devDependenciesOptions.map((curr) => ( + + {curr.relyVersion} + + ))} + + +
+ ) + }, +}) + +export default RelyAbout diff --git a/src/views/router-demo/router-demo-detail/index.tsx b/src/views/router-demo/router-demo-detail/index.tsx new file mode 100644 index 00000000..49e9005a --- /dev/null +++ b/src/views/router-demo/router-demo-detail/index.tsx @@ -0,0 +1,27 @@ +/** + * + * @author Ray + * + * @date 2023-06-30 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { NCard, NSpace } from 'naive-ui' + +const RouterDemoDetail = defineComponent({ + name: 'RouterDemoDetail', + + render() { + return ( + + 我是平层路由详情页面 + 可以点击面包屑或者菜单返回到主页面 + + ) + }, +}) + +export default RouterDemoDetail diff --git a/src/views/router-demo/router-demo-home/index.tsx b/src/views/router-demo/router-demo-home/index.tsx new file mode 100644 index 00000000..80c20c1b --- /dev/null +++ b/src/views/router-demo/router-demo-home/index.tsx @@ -0,0 +1,94 @@ +/** + * + * @author Ray + * + * @date 2023-06-30 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { NSpace, NDataTable, NButton } from 'naive-ui' + +import { useVueRouter } from '@/router/helper/useVueRouter' + +import type { DataTableColumns } from 'naive-ui' + +export interface RowData { + key: string | number + name: string + phone: string + address: string +} + +const RouterDemoHome = defineComponent({ + name: 'RouterDemoHome', + setup() { + const { router } = useVueRouter() + + const columns: DataTableColumns = [ + { + title: '姓名', + key: 'name', + }, + { + title: '地址', + key: 'address', + }, + { + title: '联系方式', + key: 'phone', + }, + { + title: '操作', + key: '', + render: (row) => { + return ( + + { + router.push({ + path: '/router-demo/router-demo-detail', + query: { + row: JSON.stringify(row), + }, + }) + }} + > + 详情 + + + ) + }, + }, + ] + const dataSource: RowData[] = [] + + for (let i = 0; i < 10; i++) { + dataSource.push({ + name: '张三', + address: 'New York No. 1 Lake Park', + phone: '010-121212', + key: i, + }) + } + + return { + dataSource, + columns, + } + }, + render() { + return ( + + + + ) + }, +}) + +export default RouterDemoHome diff --git a/src/views/scroll-reveal/index.scss b/src/views/scroll-reveal/index.scss new file mode 100644 index 00000000..b5c1d796 --- /dev/null +++ b/src/views/scroll-reveal/index.scss @@ -0,0 +1,4 @@ +.scroll-reveal { + width: 100%; + height: 100%; +} diff --git a/src/views/scroll-reveal/index.tsx b/src/views/scroll-reveal/index.tsx new file mode 100644 index 00000000..c3d96a00 --- /dev/null +++ b/src/views/scroll-reveal/index.tsx @@ -0,0 +1,18 @@ +import './index.scss' +import { NCard } from 'naive-ui' + +const ScrollReveal = defineComponent({ + name: 'ScrollReveal', + render() { + return ( +
+ + RayScrollReveal组件有点问题, 暂时移除. 不能正常的实现滚动动画. + 很是操蛋!!! + +
+ ) + }, +}) + +export default ScrollReveal diff --git a/src/views/table/index.tsx b/src/views/table/index.tsx new file mode 100644 index 00000000..31a9bf20 --- /dev/null +++ b/src/views/table/index.tsx @@ -0,0 +1,259 @@ +/** + * + * @author Ray + * + * @date 2022-12-08 + * + * @workspace ray-template + * + * @remark 今天也是元气满满撸代码的一天 + */ + +import { + NLayout, + NTag, + NButton, + NGridItem, + NSelect, + NInput, + NDatePicker, + NSwitch, + NP, + NH2, + NUl, + NLi, +} from 'naive-ui' +import RayTable from '@/components/RayTable/index' +import RayCollapseGrid from '@/components/RayCollapseGrid/index' + +import type { DataTableColumns } from 'naive-ui' +import type { RayTableInst } from '@/components/RayTable/index' + +type RowData = { + key: number + name: string + age: number + address: string + tags: string[] +} + +const TableView = defineComponent({ + name: 'TableView', + setup() { + const tableRef = ref() + + const baseColumns = [ + { + title: 'Name', + key: 'name', + }, + { + title: 'Age', + key: 'age', + }, + { + title: 'Address', + key: 'address', + }, + { + title: 'Tags', + key: 'tags', + render: (row: RowData) => { + const tags = row.tags.map((tagKey) => { + return h( + NTag, + { + style: { + marginRight: '6px', + }, + type: 'info', + bordered: false, + }, + { + default: () => tagKey, + }, + ) + }) + + return tags + }, + }, + { + title: 'Remark', + key: 'remark', + width: 300, + }, + { + title: 'Action', + key: 'actions', + render: (row: RowData) => + h( + NButton, + { + size: 'small', + }, + { default: () => 'Send Email' }, + ), + }, + ] + const actionColumns = ref>( + [...baseColumns].map((curr) => ({ ...curr, width: 400 })), + ) + const tableData = ref([ + { + key: 0, + name: 'John Brown', + age: 32, + address: 'New York No. 1 Lake Park', + tags: ['nice', 'developer'], + remark: '我是一条很长很长的备注', + }, + { + key: 1, + name: 'Jim Green', + age: 42, + address: 'London No. 1 Lake Park', + tags: ['wow'], + remark: '我是一条很长很长的备注', + }, + { + key: 2, + name: 'Joe Black', + age: 32, + address: 'Sidney No. 1 Lake Park', + tags: ['cool', 'teacher'], + remark: '我是一条很长很长的备注', + }, + ]) + const tableMenuOptions = [ + { + label: '编辑', + key: 'edit', + }, + { + label: () => h('span', { style: { color: 'red' } }, '删除'), + key: 'delete', + }, + ] + const state = reactive({ + gridItemCount: 4, + gridCollapsedRows: 1, + tableLoading: false, + }) + + const handleMenuSelect = (key: string | number, idx: number) => { + if (key === 'delete') { + tableData.value.splice(idx, 1) + } + } + + onMounted(() => { + console.log(tableRef.value?.tableMethods) + }) + + return { + ...toRefs(state), + tableData, + actionColumns, + baseColumns, + tableMenuOptions, + handleMenuSelect, + tableRef, + } + }, + render() { + return ( + + RayTable 组件使用 + + + 该组件基于 Naive UI DataTable + 组件封装。实现右键菜单、表格标题、导出为 excel 操作栏等功能 + + RayTable 完全继承 DataTable 的所有属性与方法 + + 相关拓展 props 属性,可以在源码位置 + src/components/RayTable/src/props.ts 中查看相关代码与注释 + + 该组件可以配合 RayCollapseGird 组件使用实现可折叠搜索栏 + + 配合 RayCollapseGird 组件使用与 RayTable 拓展功能 + + 使用响应式方法代理 columns 并且打开 action + 则可以启用操作栏(v-model:columns) + + 拖拽操作栏动态切换表格列 + 点击左右固定按钮,即可动态固定列 + 点击修改列宽度,即可拖动列修改宽度 + 点击导出按钮即可导出 excel 表格,默认以列为表头输出 + 点击打印按钮即可打印该表格 + 右键菜单 + 全屏表格 + + window.$message.info( + `我是 RayCollapseGrid 组件${value ? '收起' : '展开'}的回调函数`, + ) + } + > + {{ + action: () => ( + <> + 搜索 + 重置 + + ), + default: () => ( + <> + + + + + + + + + + + + + + + + + ), + }} + + (this.tableLoading = value), + }, + {}, + )} + data={this.tableData} + v-model:columns={this.actionColumns} + pagination={{ + pageSize: 10, + }} + loading={this.tableLoading} + rightClickMenu={this.tableMenuOptions} + onMenuSelect={this.handleMenuSelect.bind(this)} + > + {{ + tableFooter: () => '表格的底部内容区域插槽,有时候你可能会用上', + }} + + + ) + }, +}) + +export default TableView diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 00000000..bb2e02f1 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// +/// +/// +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..a7406f34 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,48 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM", "es5", "es6", "dom.iterable", "es2022"], + "skipLibCheck": true, + "baseUrl": "./", + "rootDir": "./", + "paths": { + "@": ["src"], + "@/*": ["src/*"], + "@use-utils": ["src/utils"], + "@use-utils/*": ["src/utils/*"], + "@use-api": ["src/axios/api"], + "@use-api/*": ["src/axios/api/*"], + "@use-images": ["src/assets/images"], + "@use-images/*": ["src/assets/images"], + "@use-micro/*": ["src/micro/*"] + }, + "suppressImplicitAnyIndexErrors": true, + "types": [ + "@intlify/unplugin-vue-i18n/messages", + "naive-ui/volar", + "vite/client", + "./src/types/global.d.ts" + ], + "ignoreDeprecations": "5.0" + }, + "include": [ + "vite.config.ts", + "vite-plugin/index.ts", + "vite-plugin/type.ts", + "cfg.ts", + "package.json", + "vite-env.d.ts", + "components.d.ts", + "auto-imports.d.ts", + "src/**/*" + ] +} diff --git a/vite-plugin/index.ts b/vite-plugin/index.ts new file mode 100644 index 00000000..23376642 --- /dev/null +++ b/vite-plugin/index.ts @@ -0,0 +1,155 @@ +import path from 'node:path' + +import autoImport from 'unplugin-auto-import/vite' // 自动导入 +import unpluginViteComponents from 'unplugin-vue-components/vite' // 自动按需导入 +import vueI18nPlugin from '@intlify/unplugin-vue-i18n/vite' // i18n +import { createSvgIconsPlugin } from 'vite-plugin-svg-icons' // `svg icon` + +import type { ComponentResolver, TypeImport } from 'unplugin-vue-components' +import type { ImportsMap, PresetName } from 'unplugin-auto-import/types' +import type { BuildOptions } from 'vite' +import type { ViteSvgIconsPlugin } from 'vite-plugin-svg-icons' + +/** + * + * @param options `svg icon` 自定义配置 + * + * 使用 `svg` 作为图标 + */ +export const viteSVGIcon = (options?: ViteSvgIconsPlugin) => { + const defaultOptions = { + iconDirs: [path.resolve(process.cwd(), 'src/icons')], + symbolId: 'icon-[dir]-[name]', + inject: 'body-last', + customDomId: '__svg__icons__dom__', + } + + return createSvgIconsPlugin(Object.assign({}, defaultOptions, options)) +} + +/** + * + * @param imp 自动导入依赖 + * @returns auto import plugin + * + * 自动导入 + */ +export const viteAutoImport = async (imp: (ImportsMap | PresetName)[] = []) => + autoImport({ + include: [ + /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx + /\.vue$/, + /\.vue\?vue/, // .vue + /\.md$/, // .md + ], + dts: true, + imports: ['vue', 'vue-router', 'pinia', '@vueuse/core', 'vue-i18n', ...imp], + }) + +/** + * + * @param resolvers 按需加载依赖项 + * @param types 按需加载依赖类型 + * + * 按需加载 + */ +export const viteComponents = async ( + resolvers: (ComponentResolver | ComponentResolver[])[] = [], + types: TypeImport[] = [], +) => + unpluginViteComponents({ + dts: true, + resolvers: [...resolvers], + types: [ + { + from: 'vue-router', + names: ['RouterLink', 'RouterView'], + }, + ...types, + ], + }) + +export const viteVueI18nPlugin = () => + vueI18nPlugin({ + runtimeOnly: true, + compositionOnly: true, + forceStringify: true, + defaultSFCLang: 'json', + include: [path.resolve(__dirname, '../locales/**')], + }) + +/** + * + * @param title 浏览器 title 名称 + */ +export const HTMLTitlePlugin = (title: string) => { + return { + name: 'html-transform', + transformIndexHtml: (html: string) => { + return html.replace(/(.*?)<\/title>/, `<title>${title}`) + }, + } +} + +/** + * + * @param mode 打包环境 + * + * @remark 打包输出文件配置 + */ +export const buildOptions = (mode: string): BuildOptions => { + const outDirMap = { + test: 'dist/test-dist', + development: 'dist/development-dist', + production: 'dist/production-dist', + report: 'dist/report-dist', + } + const dirPath = outDirMap[mode] || 'dist/test-dist' + + if (mode === 'production') { + return { + outDir: dirPath, + sourcemap: false, + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true, + }, + }, + } + } else { + return { + outDir: dirPath, + sourcemap: true, + terserOptions: { + compress: { + drop_console: false, + drop_debugger: false, + }, + }, + } + } +} + +/** + * + * @param options 预处理 css 文件 + * @returns additionalData string + * + * @remark 辅助处理需要全局注入的 css 样式文件, 会在构建期间完成注入 + */ +export const mixinCSSPlugin = (options?: string[]) => { + const defaultOptions = [] + + if (Array.isArray(options)) { + defaultOptions.push(...options) + } + + const mixisString = defaultOptions.reduce((pre, curr) => { + const temp = `@import "${curr}";` + + return (pre += temp) + }, '') + + return mixisString as string +} diff --git a/vite-plugin/type.ts b/vite-plugin/type.ts new file mode 100644 index 00000000..04ce9c8e --- /dev/null +++ b/vite-plugin/type.ts @@ -0,0 +1,129 @@ +export interface VitePluginCompression { + /** + * + * Log compressed files and their compression ratios. + * @default: true + */ + verbose?: boolean + /** + * + * Minimum file size before compression is used. + * @default 1025 + */ + threshold?: number + /** + * + * Filter files that do not need to be compressed + * @default /\.(js|mjs|json|css|html)$/i + */ + filter?: RegExp | ((file: string) => boolean) + /** + * + * Whether to enable compression + * @default: false + */ + disable?: boolean + /** + * + * Compression algorithm + * @default gzip + */ + algorithm?: Algorithm + /** + * + * File format after compression + * @default .gz + */ + ext?: string + /** + * + * Compression Options + */ + compressionOptions?: object + /** + * + * Delete the corresponding source file after compressing the file + * @default: false + */ + deleteOriginFile?: boolean + /** + * + * success callback after completed + */ + success?: () => void +} + +export interface ViteBuildPlugin { + outDir: string + assetsDir: string + assetsInlineLimit: number + cssCodeSplit: boolean // 拆分css代码 + minify: boolean | 'esbuild' | 'terser' + sourcemap: boolean +} + +export interface LibItem { + /** + * + * library name + */ + libName: string + /** + * + * component style file path + */ + style?: (name: string) => string | string[] | boolean + /** + * + * default `es` + */ + libDirectory?: string + /** + * + * whether convert component name from camel to dash, default `true` + */ + camel2DashComponentName?: boolean + /** + * + * whether replace old import statement, default `command === 'build'`, + * that means in vite serve default to `false`, in vite build default to `ture` + */ + replaceOldImport?: boolean + /** + * + * imported name formatter + */ + nameFormatter?: (name: string, importedName: string) => string +} + +export interface LibResolverObject extends LibItem {} + +export type LibResolver = LibResolverObject + +export interface ImpConfig { + optimize?: boolean + libList: LibResolver[] + /** + * + * exclude the library from defaultLibList + */ + exclude?: string[] + /** + * + * when a style path is not found, don’t show error and give a warning. + * Default: command === 'serve' + */ + ignoreStylePathNotFound?: boolean + /** + * + * By default `vite-plugin-imp` ignores all files inside node_modules. + * You can enable this option to avoid unexpected untranspiled code from third-party dependencies. + * + * Transpiling all the dependencies could slow down the build process, though. + * If build performance is a concern, you can explicitly transpile only some of the dependencies + * by passing an array of package names or name patterns to this option. + * + * Default: false + */ + transpileDependencies?: boolean | Array +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..9fa84a54 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,176 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +import { + viteAutoImport, + viteComponents, + viteVueI18nPlugin, + viteSVGIcon, +} from './vite-plugin/index' +import viteVueJSX from '@vitejs/plugin-vue-jsx' +import viteVeI18nPlugin from '@intlify/unplugin-vue-i18n/vite' +import viteInspect from 'vite-plugin-inspect' +import viteSvgLoader from 'vite-svg-loader' +import viteEslintPlugin from 'vite-plugin-eslint' +import vitePluginImp from 'vite-plugin-imp' // 按需打包工具 +import { visualizer } from 'rollup-plugin-visualizer' // 打包体积分析工具 +import viteCompression from 'vite-plugin-compression' // 压缩打包 +import { ViteEjsPlugin as viteEjsPlugin } from 'vite-plugin-ejs' + +import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' // 模板自动导入组件并且按需打包 + +import config from './cfg' +import pkg from './package.json' + +const { dependencies, devDependencies, name, version } = pkg +const { + server, + buildOptions, + alias, + title, + copyright, + sideBarLogo, + mixinCSS, + appPrimaryColor, + preloadingConfig, + base, +} = config + +/** + * + * 全局注入 `__APP_CFG__` 变量 + * + * 可以在 `views` 页面使用 + * + * 使用方法 `const { pkg, layout } = __APP_CFG__` + * + * 如果有新的补充, 需要自己手动补充类型 `src/types/cfg.ts AppConfig` + */ +const __APP_CFG__ = { + pkg: { dependencies, devDependencies, name, version }, + layout: { + copyright, + sideBarLogo, + }, + appPrimaryColor, +} + +// https://vitejs.dev/config/ +export default defineConfig(async ({ mode }) => { + return { + base: base || '/', + define: { + __APP_CFG__: JSON.stringify(__APP_CFG__), + }, + resolve: { + alias: alias, + }, + plugins: [ + vue({ reactivityTransform: true }), + viteVueJSX(), + title, + viteInspect(), // 仅适用于开发模式(检查 `Vite` 插件的中间状态) + viteVeI18nPlugin(), + await viteAutoImport([ + { + 'naive-ui': [ + 'useDialog', + 'useMessage', + 'useNotification', + 'useLoadingBar', + ], + }, + ]), + await viteComponents([NaiveUiResolver()]), + viteCompression(), + viteVueI18nPlugin(), + viteSvgLoader({ + defaultImport: 'component', // 默认以 `componetn` 形式导入 `svg` + }), + viteSVGIcon(), + viteEslintPlugin({ + lintOnStart: true, // 构建时自动检查 + failOnWarning: true, // 如果含有警告则构建失败 + failOnError: true, // 如果有错误则构建失败 + cache: true, // 缓存, 减少构建时间 + exclude: ['**/node_modules/**', 'vite-env.d.ts'], + include: ['src/**/*.ts', 'src/**/*.vue', 'src/**/*.tsx'], + }), + vitePluginImp({ + libList: [ + { + libName: 'lodash-es', + libDirectory: '', + camel2DashComponentName: false, + }, + { + libName: '@vueuse', + libDirectory: '', + camel2DashComponentName: false, + }, + { + libName: 'lodash', + libDirectory: '', + camel2DashComponentName: false, + }, + ], + }), + { + include: [ + 'src/**/*.ts', + 'src/**/*.tsx', + 'src/**/*.vue', + 'src/*.ts', + 'src/*.tsx', + 'src/*.vue', + ], + }, + visualizer({ + gzipSize: true, // 搜集 `gzip` 压缩包 + brotliSize: true, // 搜集 `brotli` 压缩包 + emitFile: false, // 生成文件在根目录下 + filename: 'visualizer.html', + open: mode === 'report' ? true : false, // 以默认服务器代理打开文件 + }), + viteEjsPlugin({ + preloadingConfig, + appPrimaryColor, + }), + ], + optimizeDeps: { + include: ['vue', 'vue-router', 'pinia', 'vue-i18n', '@vueuse/core'], + }, + esbuild: { + pure: ['console.log'], + }, + build: { + ...buildOptions(mode), + rollupOptions: { + output: { + manualChunks: (id) => { + if (id.includes('node_modules')) { + return id + .toString() + .split('node_modules/')[1] + .split('/')[0] + .toString() + } + }, + }, + }, + }, + css: { + preprocessorOptions: { + scss: { + additionalData: mixinCSS, + }, + }, + modules: { + localsConvention: 'camelCaseOnly', + }, + }, + server: { + ...server, + }, + } +})