Browse Source

release: update `5.1.0`

release: update `5.1.0`
i18n
xiaoming 9 months ago
committed by GitHub
parent
commit
5a0ce44307
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .browserslistrc
  2. 2
      .env.development
  3. 2
      .env.production
  4. 2
      .env.staging
  5. 2
      .eslintignore
  6. 1
      .gitignore
  7. 2
      .husky/commit-msg
  8. 8
      .husky/lintstagedrc.js
  9. 6
      .husky/pre-commit
  10. 16
      .lintstagedrc
  11. 1
      .nvmrc
  12. 5
      .prettierrc.js
  13. 2
      .vscode/settings.json
  14. 4
      Dockerfile
  15. 2
      README.en-US.md
  16. 2
      README.md
  17. 1
      build/cdn.ts
  18. 31
      build/index.ts
  19. 36
      build/info.ts
  20. 4
      build/optimize.ts
  21. 54
      build/plugins.ts
  22. 110
      build/utils.ts
  23. 5
      commitlint.config.js
  24. 174
      eslint.config.js
  25. 2
      locales/en.yaml
  26. 2
      locales/zh-CN.yaml
  27. 17
      mock/asyncRoutes.ts
  28. 10
      mock/login.ts
  29. 10
      mock/refreshToken.ts
  30. 207
      package.json
  31. 10473
      pnpm-lock.yaml
  32. 5
      postcss.config.js
  33. 2
      public/logo.svg
  34. 11
      public/platform-config.json
  35. 4
      src/App.vue
  36. 2
      src/api/routes.ts
  37. 2
      src/api/user.ts
  38. 3
      src/assets/iconfont/iconfont.css
  39. 2
      src/assets/login/avatar.svg
  40. 2
      src/assets/login/illustration.svg
  41. 2
      src/assets/status/403.svg
  42. 2
      src/assets/status/404.svg
  43. 2
      src/assets/status/500.svg
  44. 2
      src/assets/svg/back_top.svg
  45. 2
      src/assets/svg/dark.svg
  46. 2
      src/assets/svg/day.svg
  47. 2
      src/assets/svg/enter_outlined.svg
  48. 2
      src/assets/svg/exit_screen.svg
  49. 2
      src/assets/svg/full_screen.svg
  50. 2
      src/assets/svg/keyboard_esc.svg
  51. 1
      src/assets/svg/system.svg
  52. 7
      src/components/ReDialog/index.ts
  53. 6
      src/components/ReDialog/index.vue
  54. 6
      src/components/ReDialog/type.ts
  55. 6
      src/components/ReIcon/src/hooks.ts
  56. 2
      src/components/ReIcon/src/iconifyIconOffline.ts
  57. 18
      src/components/ReIcon/src/offlineIcon.ts
  58. 3
      src/components/ReIcon/src/types.ts
  59. 65
      src/components/RePureTableBar/src/bar.tsx
  60. 2
      src/components/RePureTableBar/src/svg/collapse.svg
  61. 2
      src/components/RePureTableBar/src/svg/drag.svg
  62. 2
      src/components/RePureTableBar/src/svg/expand.svg
  63. 2
      src/components/RePureTableBar/src/svg/settings.svg
  64. 8
      src/components/ReSegmented/index.ts
  65. 80
      src/components/ReSegmented/src/index.css
  66. 166
      src/components/ReSegmented/src/index.tsx
  67. 20
      src/components/ReSegmented/src/type.ts
  68. 7
      src/components/ReText/index.ts
  69. 62
      src/components/ReText/src/index.vue
  70. 10
      src/config/index.ts
  71. 1
      src/directives/index.ts
  72. 48
      src/directives/ripple/index.scss
  73. 234
      src/directives/ripple/index.ts
  74. 112
      src/layout/components/appMain.vue
  75. 31
      src/layout/components/footer/index.vue
  76. 80
      src/layout/components/keepAliveFrame/index.vue
  77. 25
      src/layout/components/keepAliveFrame/useMultiFrame.ts
  78. 10
      src/layout/components/navbar.vue
  79. 4
      src/layout/components/notice/data.ts
  80. 2
      src/layout/components/notice/index.vue
  81. 4
      src/layout/components/notice/noticeList.vue
  82. 105
      src/layout/components/panel/index.vue
  83. 8
      src/layout/components/search/components/SearchFooter.vue
  84. 198
      src/layout/components/search/components/SearchHistory.vue
  85. 53
      src/layout/components/search/components/SearchHistoryItem.vue
  86. 226
      src/layout/components/search/components/SearchModal.vue
  87. 27
      src/layout/components/search/components/SearchResult.vue
  88. 5
      src/layout/components/search/index.vue
  89. 20
      src/layout/components/search/types.ts
  90. 387
      src/layout/components/setting/index.vue
  91. 26
      src/layout/components/sidebar/breadCrumb.vue
  92. 12
      src/layout/components/sidebar/horizontal.vue
  93. 36
      src/layout/components/sidebar/leftCollapse.vue
  94. 36
      src/layout/components/sidebar/linkItem.vue
  95. 7
      src/layout/components/sidebar/logo.vue
  96. 11
      src/layout/components/sidebar/mixNav.vue
  97. 209
      src/layout/components/sidebar/sidebarItem.vue
  98. 15
      src/layout/components/sidebar/vertical.vue
  99. 93
      src/layout/components/tag/index.scss
  100. 94
      src/layout/components/tag/index.vue

4
.browserslistrc

@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

2
.env.development

@ -2,7 +2,7 @@
VITE_PORT = 8848
# 开发环境读取配置文件路径
VITE_PUBLIC_PATH = ./
VITE_PUBLIC_PATH = /
# 开发环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数")
VITE_ROUTER_HISTORY = "hash"

2
.env.production

@ -1,5 +1,5 @@
# 线上环境平台打包路径
VITE_PUBLIC_PATH = ./
VITE_PUBLIC_PATH = /
# 线上环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数")
VITE_ROUTER_HISTORY = "hash"

2
.env.staging

@ -2,7 +2,7 @@
# https://cn.vitejs.dev/guide/env-and-mode.html#modes
# NODE_ENV = development
VITE_PUBLIC_PATH = ./
VITE_PUBLIC_PATH = /
# 预发布环境路由历史模式(Hash模式传"hash"、HTML5模式传"h5"、Hash模式带base参数传"hash,base参数"、HTML5模式带base参数传"h5,base参数")
VITE_ROUTER_HISTORY = "hash"

2
.eslintignore

@ -3,7 +3,7 @@ dist
*.d.ts
/src/assets
package.json
.eslintrc.js
eslint.config.js
.prettierrc.js
commitlint.config.js
postcss.config.js

1
.gitignore

@ -5,6 +5,7 @@ dist-ssr
*.local
.eslintcache
report.html
vite.config.*.timestamp*
yarn.lock
npm-debug.log*

2
.husky/commit-msg

@ -3,4 +3,6 @@
# shellcheck source=./_/husky.sh
. "$(dirname "$0")/_/husky.sh"
PATH="/usr/local/bin:$PATH"
npx --no-install commitlint --edit "$1"

8
.husky/lintstagedrc.js

@ -1,8 +0,0 @@
module.exports = {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"{!(package)*.json}": ["prettier --write--parser json"],
"package.json": ["prettier --write"],
"*.vue": ["eslint --fix", "prettier --write", "stylelint --fix"],
"*.{vue,css,scss,postcss,less}": ["stylelint --fix", "prettier --write"],
"*.md": ["prettier --write"]
};

6
.husky/pre-commit

@ -4,7 +4,7 @@
[ -n "$CI" ] && exit 0
# Format and submit code according to lintstagedrc.js configuration
npm run lint:lint-staged
PATH="/usr/local/bin:$PATH"
npm run lint:pretty
# Perform lint check on files in the staging area through .lintstagedrc configuration
pnpm exec lint-staged

16
.lintstagedrc

@ -0,0 +1,16 @@
{
"*.{js,jsx,ts,tsx}": [
"prettier --cache --ignore-unknown --write",
"eslint --cache --fix"
],
"{!(package)*.json,*.code-snippets,.!({browserslist,nvm})*rc}": [
"prettier --cache --write--parser json"
],
"package.json": ["prettier --cache --write"],
"*.vue": ["prettier --write", "eslint --cache --fix", "stylelint --fix"],
"*.{css,scss,html}": [
"prettier --cache --ignore-unknown --write",
"stylelint --fix"
],
"*.md": ["prettier --cache --ignore-unknown --write"]
}

1
.nvmrc

@ -0,0 +1 @@
v20.11.1

5
.prettierrc.js

@ -1,4 +1,7 @@
module.exports = {
// @ts-check
/** @type {import("prettier").Config} */
export default {
bracketSpacing: true,
singleQuote: false,
arrowParens: "avoid",

2
.vscode/settings.json

@ -25,7 +25,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"i18n-ally.localesPaths": "locales",
"i18n-ally.keystyle": "nested",

4
Dockerfile

@ -1,8 +1,8 @@
FROM node:16-alpine as build-stage
FROM node:18-alpine as build-stage
WORKDIR /app
RUN corepack enable
RUN corepack prepare pnpm@7.32.1 --activate
RUN corepack prepare pnpm@8.6.10 --activate
RUN npm config set registry https://registry.npmmirror.com

2
README.en-US.md

@ -31,6 +31,4 @@ The simplified version is based on the shelf extracted from [vue-pure-admin](htt
## License
In principle, no fees and copyrights are charged, and it is commercially available, but if you need secondary open source (such as using this platform for secondary development and open source, the front-end code must be open source and free), please contact the author for permission! (Free, just take a record)
[MIT © 2020-present, pure-admin](./LICENSE)

2
README.md

@ -35,6 +35,4 @@
## 许可证
原则上不收取任何费用及版权,可商用,不过如需二次开源(比如用此平台二次开发并开源,要求前端代码必须开源免费)请联系作者获取许可!(免费,走个记录而已)
[MIT © 2020-present, pure-admin](./LICENSE)

1
build/cdn.ts

@ -3,7 +3,6 @@ import { Plugin as importToCDN } from "vite-plugin-cdn-import";
/**
* @description `cdn`使cdn模式 .env.production VITE_CDN true
* cdnhttps://www.bootcdn.cn,当然你也可以选择 https://unpkg.com 或者 https://www.jsdelivr.com
* mockjs不能用cdn模式引入mockjs使
* 使jscss文件cdn
*/
export const cdn = importToCDN({

31
build/index.ts

@ -1,31 +0,0 @@
/** 处理环境变量 */
const warpperEnv = (envConf: Recordable): ViteEnv => {
/** 此处为默认值 */
const ret: ViteEnv = {
VITE_PORT: 8848,
VITE_PUBLIC_PATH: "",
VITE_ROUTER_HISTORY: "",
VITE_CDN: false,
VITE_HIDE_HOME: "false",
VITE_COMPRESSION: "none"
};
for (const envName of Object.keys(envConf)) {
let realName = envConf[envName].replace(/\\n/g, "\n");
realName =
realName === "true" ? true : realName === "false" ? false : realName;
if (envName === "VITE_PORT") {
realName = Number(realName);
}
ret[envName] = realName;
if (typeof realName === "string") {
process.env[envName] = realName;
} else if (typeof realName === "object") {
process.env[envName] = JSON.stringify(realName);
}
}
return ret;
};
export { warpperEnv };

36
build/info.ts

@ -1,10 +1,21 @@
import type { Plugin } from "vite";
import dayjs, { Dayjs } from "dayjs";
import utils from "@pureadmin/utils";
import { getPackageSize } from "./utils";
import dayjs, { type Dayjs } from "dayjs";
import duration from "dayjs/plugin/duration";
import { green, blue, bold } from "picocolors";
import gradientString from "gradient-string";
import boxen, { type Options as BoxenOptions } from "boxen";
dayjs.extend(duration);
const welcomeMessage = gradientString("cyan", "magenta").multiline(
`Hello! 欢迎使用 pure-admin 开源项目\n我们为您精心准备了下面两个贴心的保姆级文档\nhttps://yiming_chang.gitee.io/pure-admin-doc\nhttps://pure-admin-utils.netlify.app`
);
const boxenOptions: BoxenOptions = {
padding: 0.5,
borderColor: "cyan",
borderStyle: "round"
};
export function viteBuildInfo(): Plugin {
let config: { command: string };
let startTime: Dayjs;
@ -17,15 +28,7 @@ export function viteBuildInfo(): Plugin {
outDir = resolvedConfig.build?.outDir ?? "dist";
},
buildStart() {
console.log(
bold(
green(
`👏欢迎使用${blue(
"[vue-pure-admin]"
)}star哦💖 https://github.com/pure-admin/vue-pure-admin`
)
)
);
console.log(boxen(welcomeMessage, boxenOptions));
if (config.command === "build") {
startTime = dayjs(new Date());
}
@ -33,16 +36,17 @@ export function viteBuildInfo(): Plugin {
closeBundle() {
if (config.command === "build") {
endTime = dayjs(new Date());
utils.getPackageSize({
getPackageSize({
folder: outDir,
callback: (size: string) => {
console.log(
bold(
green(
boxen(
gradientString("cyan", "magenta").multiline(
`🎉 恭喜打包完成(总用时${dayjs
.duration(endTime.diff(startTime))
.format("mm分ss秒")}${size}`
)
),
boxenOptions
)
);
}

4
build/optimize.ts

@ -11,9 +11,11 @@ const include = [
"axios",
"pinia",
"vue-i18n",
"vue-types",
"js-cookie",
"sortablejs",
"vue-tippy",
"pinyin-pro",
"sortablejs",
"@vueuse/core",
"@pureadmin/utils",
"responsive-storage"

54
build/plugins.ts

@ -1,39 +1,47 @@
import { cdn } from "./cdn";
import { resolve } from "path";
import vue from "@vitejs/plugin-vue";
import { pathResolve } from "./utils";
import { viteBuildInfo } from "./info";
import svgLoader from "vite-svg-loader";
import type { PluginOption } from "vite";
import vueJsx from "@vitejs/plugin-vue-jsx";
import { viteMockServe } from "vite-plugin-mock";
import { configCompressPlugin } from "./compress";
// import ElementPlus from "unplugin-element-plus/vite";
import removeNoMatch from "vite-plugin-router-warn";
import { visualizer } from "rollup-plugin-visualizer";
import removeConsole from "vite-plugin-remove-console";
import themePreprocessorPlugin from "@pureadmin/theme";
import { themePreprocessorPlugin } from "@pureadmin/theme";
import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite";
import { genScssMultipleScopeVars } from "../src/layout/theme";
import { vitePluginFakeServer } from "vite-plugin-fake-server";
export function getPluginsList(
command: string,
VITE_CDN: boolean,
VITE_COMPRESSION: ViteCompression
) {
const prodMock = true;
): PluginOption[] {
const lifecycle = process.env.npm_lifecycle_event;
return [
vue(),
// jsx、tsx语法支持
vueJsx(),
VueI18nPlugin({
runtimeOnly: true,
compositionOnly: true,
include: [resolve("locales/**")]
include: [pathResolve("../locales/**")]
}),
// jsx、tsx语法支持
vueJsx(),
VITE_CDN ? cdn : null,
configCompressPlugin(VITE_COMPRESSION),
// 线上环境删除console
removeConsole({ external: ["src/assets/iconfont/iconfont.js"] }),
viteBuildInfo(),
/**
* vue-router动态路由警告No match found for location with path
* https://github.com/vuejs/router/issues/521 和 https://github.com/vuejs/router/issues/359
* vite-plugin-router-warn只在开发环境下启用vue-router文件并且只在服务启动或重启时运行一次
*/
removeNoMatch(),
// mock支持
vitePluginFakeServer({
logger: false,
include: "mock",
infixName: false,
enableProd: true
}),
// 自定义主题
themePreprocessorPlugin({
scss: {
@ -43,21 +51,13 @@ export function getPluginsList(
}),
// svg组件化支持
svgLoader(),
// ElementPlus({}),
// mock支持
viteMockServe({
mockPath: "mock",
localEnabled: command === "serve",
prodEnabled: command !== "serve" && prodMock,
injectCode: `
import { setupProdMockServer } from './mockProdServer';
setupProdMockServer();
`,
logger: false
}),
VITE_CDN ? cdn : null,
configCompressPlugin(VITE_COMPRESSION),
// 线上环境删除console
removeConsole({ external: ["src/assets/iconfont/iconfont.js"] }),
// 打包分析
lifecycle === "report"
? visualizer({ open: true, brotliSize: true, filename: "report.html" })
: null
: (null as any)
];
}

110
build/utils.ts

@ -0,0 +1,110 @@
import dayjs from "dayjs";
import { readdir, stat } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { sum, formatBytes } from "@pureadmin/utils";
import {
name,
version,
engines,
dependencies,
devDependencies
} from "../package.json";
/** 启动`node`进程时所在工作目录的绝对路径 */
const root: string = process.cwd();
/**
* @description
* @param dir `build`
* @param metaUrl `url``build``import.meta.url`
*/
const pathResolve = (dir = ".", metaUrl = import.meta.url) => {
// 当前文件目录的绝对路径
const currentFileDir = dirname(fileURLToPath(metaUrl));
// build 目录的绝对路径
const buildDir = resolve(currentFileDir, "build");
// 解析的绝对路径
const resolvedPath = resolve(currentFileDir, dir);
// 检查解析的绝对路径是否在 build 目录内
if (resolvedPath.startsWith(buildDir)) {
// 在 build 目录内,返回当前文件路径
return fileURLToPath(metaUrl);
}
// 不在 build 目录内,返回解析后的绝对路径
return resolvedPath;
};
/** 设置别名 */
const alias: Record<string, string> = {
"@": pathResolve("../src"),
"@build": pathResolve()
};
/** 平台的名称、版本、运行所需的`node`和`pnpm`版本、依赖、最后构建时间的类型提示 */
const __APP_INFO__ = {
pkg: { name, version, engines, dependencies, devDependencies },
lastBuildTime: dayjs(new Date()).format("YYYY-MM-DD HH:mm:ss")
};
/** 处理环境变量 */
const warpperEnv = (envConf: Recordable): ViteEnv => {
// 默认值
const ret: ViteEnv = {
VITE_PORT: 8848,
VITE_PUBLIC_PATH: "",
VITE_ROUTER_HISTORY: "",
VITE_CDN: false,
VITE_HIDE_HOME: "false",
VITE_COMPRESSION: "none"
};
for (const envName of Object.keys(envConf)) {
let realName = envConf[envName].replace(/\\n/g, "\n");
realName =
realName === "true" ? true : realName === "false" ? false : realName;
if (envName === "VITE_PORT") {
realName = Number(realName);
}
ret[envName] = realName;
if (typeof realName === "string") {
process.env[envName] = realName;
} else if (typeof realName === "object") {
process.env[envName] = JSON.stringify(realName);
}
}
return ret;
};
const fileListTotal: number[] = [];
/** 获取指定文件夹中所有文件的总大小 */
const getPackageSize = options => {
const { folder = "dist", callback, format = true } = options;
readdir(folder, (err, files: string[]) => {
if (err) throw err;
let count = 0;
const checkEnd = () => {
++count == files.length &&
callback(format ? formatBytes(sum(fileListTotal)) : sum(fileListTotal));
};
files.forEach((item: string) => {
stat(`${folder}/${item}`, async (err, stats) => {
if (err) throw err;
if (stats.isFile()) {
fileListTotal.push(stats.size);
checkEnd();
} else if (stats.isDirectory()) {
getPackageSize({
folder: `${folder}/${item}/`,
callback: checkEnd
});
}
});
});
files.length === 0 && callback(0);
});
};
export { root, pathResolve, alias, __APP_INFO__, warpperEnv, getPackageSize };

5
commitlint.config.js

@ -1,4 +1,7 @@
module.exports = {
// @ts-check
/** @type {import("@commitlint/types").UserConfig} */
export default {
ignores: [commit => commit.includes("init")],
extends: ["@commitlint/config-conventional"],
rules: {

174
eslint.config.js

@ -0,0 +1,174 @@
import js from "@eslint/js";
import pluginVue from "eslint-plugin-vue";
import * as parserVue from "vue-eslint-parser";
import configPrettier from "eslint-config-prettier";
import pluginPrettier from "eslint-plugin-prettier";
import { defineFlatConfig } from "eslint-define-config";
import * as parserTypeScript from "@typescript-eslint/parser";
import pluginTypeScript from "@typescript-eslint/eslint-plugin";
export default defineFlatConfig([
{
...js.configs.recommended,
ignores: ["src/assets/**", "src/**/iconfont/**"],
languageOptions: {
globals: {
// index.d.ts
RefType: "readonly",
EmitType: "readonly",
TargetContext: "readonly",
ComponentRef: "readonly",
ElRef: "readonly",
ForDataType: "readonly",
AnyFunction: "readonly",
PropType: "readonly",
Writable: "readonly",
Nullable: "readonly",
NonNullable: "readonly",
Recordable: "readonly",
ReadonlyRecordable: "readonly",
Indexable: "readonly",
DeepPartial: "readonly",
Without: "readonly",
Exclusive: "readonly",
TimeoutHandle: "readonly",
IntervalHandle: "readonly",
Effect: "readonly",
ChangeEvent: "readonly",
WheelEvent: "readonly",
ImportMetaEnv: "readonly",
Fn: "readonly",
PromiseFn: "readonly",
ComponentElRef: "readonly",
parseInt: "readonly",
parseFloat: "readonly"
}
},
plugins: {
prettier: pluginPrettier
},
rules: {
...configPrettier.rules,
...pluginPrettier.configs.recommended.rules,
"no-debugger": "off",
"no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
],
"prettier/prettier": [
"error",
{
endOfLine: "auto"
}
]
}
},
{
files: ["**/*.?([cm])ts", "**/*.?([cm])tsx"],
languageOptions: {
parser: parserTypeScript,
parserOptions: {
sourceType: "module"
}
},
plugins: {
"@typescript-eslint": pluginTypeScript
},
rules: {
...pluginTypeScript.configs.strict.rules,
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/no-redeclare": "error",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/prefer-as-const": "warn",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/consistent-type-imports": [
"error",
{ disallowTypeAnnotations: false, fixStyle: "inline-type-imports" }
],
"@typescript-eslint/prefer-literal-enum-member": [
"error",
{ allowBitwiseExpressions: true }
],
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
]
}
},
{
files: ["**/*.d.ts"],
rules: {
"eslint-comments/no-unlimited-disable": "off",
"import/no-duplicates": "off",
"unused-imports/no-unused-vars": "off"
}
},
{
files: ["**/*.?([cm])js"],
rules: {
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-var-requires": "off"
}
},
{
files: ["**/*.vue"],
languageOptions: {
globals: {
$: "readonly",
$$: "readonly",
$computed: "readonly",
$customRef: "readonly",
$ref: "readonly",
$shallowRef: "readonly",
$toRef: "readonly"
},
parser: parserVue,
parserOptions: {
ecmaFeatures: {
jsx: true
},
extraFileExtensions: [".vue"],
parser: "@typescript-eslint/parser",
sourceType: "module"
}
},
plugins: {
vue: pluginVue
},
processor: pluginVue.processors[".vue"],
rules: {
...pluginVue.configs.base.rules,
...pluginVue.configs["vue3-essential"].rules,
...pluginVue.configs["vue3-recommended"].rules,
"no-undef": "off",
"no-unused-vars": "off",
"vue/no-v-html": "off",
"vue/require-default-prop": "off",
"vue/require-explicit-emits": "off",
"vue/multi-word-component-names": "off",
"vue/no-setup-props-reactivity-loss": "off",
"vue/html-self-closing": [
"error",
{
html: {
void: "always",
normal: "always",
component: "always"
},
svg: "always",
math: "always"
}
]
}
}
]);

2
locales/en.yaml

@ -37,6 +37,8 @@ status:
login:
username: Username
password: Password
remember: days no need to login
rememberInfo: After checking and logging in, will automatically log in to the system without entering your username and password within the specified number of days.
login: Login
usernameReg: Please enter username
passwordReg: Please enter password

2
locales/zh-CN.yaml

@ -37,6 +37,8 @@ status:
login:
username: 账号
password: 密码
remember: 天内免登录
rememberInfo: 勾选并登录后,规定天数内无需输入用户名和密码会自动登入系统
login: 登录
usernameReg: 请输入账号
passwordReg: 请输入密码

17
mock/asyncRoutes.ts

@ -1,17 +1,16 @@
// 模拟后端动态生成路由
import { MockMethod } from "vite-plugin-mock";
import { defineFakeRoute } from "vite-plugin-fake-server/client";
/**
* roles "admin""common"
* admin
* common
*/
const permissionRouter = {
path: "/permission",
meta: {
title: "menus.permission",
icon: "lollipop",
icon: "ep:lollipop",
rank: 10
},
children: [
@ -29,15 +28,19 @@ const permissionRouter = {
meta: {
title: "menus.permissionButton",
roles: ["admin", "common"],
auths: ["btn_add", "btn_edit", "btn_delete"]
auths: [
"permission:btn:add",
"permission:btn:edit",
"permission:btn:delete"
]
}
}
]
};
export default [
export default defineFakeRoute([
{
url: "/getAsyncRoutes",
url: "/get-async-routes",
method: "get",
response: () => {
return {
@ -46,4 +49,4 @@ export default [
};
}
}
] as MockMethod[];
]);

10
mock/login.ts

@ -1,7 +1,7 @@
// 根据角色动态生成路由
import { MockMethod } from "vite-plugin-mock";
import { defineFakeRoute } from "vite-plugin-fake-server/client";
export default [
export default defineFakeRoute([
{
url: "/login",
method: "post",
@ -15,7 +15,7 @@ export default [
roles: ["admin"],
accessToken: "eyJhbGciOiJIUzUxMiJ9.admin",
refreshToken: "eyJhbGciOiJIUzUxMiJ9.adminRefresh",
expires: "2023/10/30 00:00:00"
expires: "2030/10/30 00:00:00"
}
};
} else {
@ -27,10 +27,10 @@ export default [
roles: ["common"],
accessToken: "eyJhbGciOiJIUzUxMiJ9.common",
refreshToken: "eyJhbGciOiJIUzUxMiJ9.commonRefresh",
expires: "2023/10/30 00:00:00"
expires: "2030/10/30 00:00:00"
}
};
}
}
}
] as MockMethod[];
]);

10
mock/refreshToken.ts

@ -1,9 +1,9 @@
import { MockMethod } from "vite-plugin-mock";
import { defineFakeRoute } from "vite-plugin-fake-server/client";
// 模拟刷新token接口
export default [
export default defineFakeRoute([
{
url: "/refreshToken",
url: "/refresh-token",
method: "post",
response: ({ body }) => {
if (body.refreshToken) {
@ -13,7 +13,7 @@ export default [
accessToken: "eyJhbGciOiJIUzUxMiJ9.newAdmin",
refreshToken: "eyJhbGciOiJIUzUxMiJ9.newAdminRefresh",
// `expires`选择这种日期格式是为了方便调试,后端直接设置时间戳或许更方便(每次都应该递增)。如果后端返回的是时间戳格式,前端开发请来到这个目录`src/utils/auth.ts`,把第`38`行的代码换成expires = data.expires即可。
expires: "2023/10/30 23:59:59"
expires: "2030/10/30 23:59:59"
}
};
} else {
@ -24,4 +24,4 @@ export default [
}
}
}
] as MockMethod[];
]);

207
package.json

@ -1,7 +1,8 @@
{
"name": "pure-admin-thin",
"version": "4.5.0",
"version": "5.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "NODE_OPTIONS=--max-old-space-size=4096 vite",
"serve": "pnpm dev",
@ -11,127 +12,139 @@
"preview": "vite preview",
"preview:build": "pnpm build && vite preview",
"typecheck": "tsc --noEmit && vue-tsc --noEmit --skipLibCheck",
"svgo": "svgo -f src/assets/svg -o src/assets/svg",
"svgo": "svgo -f . -r",
"cloc": "NODE_OPTIONS=--max-old-space-size=4096 cloc . --exclude-dir=node_modules --exclude-lang=YAML",
"clean:cache": "rimraf node_modules && rimraf .eslintcache && pnpm install",
"clean:cache": "rimraf .eslintcache && rimraf pnpm-lock.yaml && rimraf node_modules && pnpm store prune && pnpm install",
"lint:eslint": "eslint --cache --max-warnings 0 \"{src,mock,build}/**/*.{vue,js,ts,tsx}\" --fix",
"lint:prettier": "prettier --write \"src/**/*.{js,ts,json,tsx,css,scss,vue,html,md}\"",
"lint:stylelint": "stylelint \"**/*.{html,vue,css,scss}\" --fix --cache --cache-location node_modules/.cache/stylelint/",
"lint:lint-staged": "lint-staged -c ./.husky/lintstagedrc.js",
"lint:pretty": "pretty-quick --staged",
"lint:stylelint": "stylelint --cache --fix \"**/*.{html,vue,css,scss}\" --cache-location node_modules/.cache/stylelint/",
"lint": "pnpm lint:eslint && pnpm lint:prettier && pnpm lint:stylelint",
"prepare": "husky install",
"prepare": "husky",
"preinstall": "npx only-allow pnpm"
},
"browserslist": [
"> 1%",
"not ie 11",
"not op_mini all"
"keywords": [
"vue-pure-admin",
"element-plus",
"tailwindcss",
"pure-admin",
"typescript",
"pinia",
"vue3",
"vite",
"esm"
],
"homepage": "https://github.com/pure-admin/pure-admin-thin/tree/i18n",
"repository": {
"type": "git",
"url": "git+https://github.com/pure-admin/pure-admin-thin.git"
},
"bugs": {
"url": "https://github.com/pure-admin/vue-pure-admin/issues"
},
"license": "MIT",
"author": {
"name": "xiaoxian521",
"email": "pureadmin@163.com",
"url": "https://github.com/xiaoxian521"
},
"dependencies": {
"@pureadmin/descriptions": "^1.1.1",
"@pureadmin/table": "^2.3.2",
"@pureadmin/utils": "^1.9.6",
"@vueuse/core": "^10.2.0",
"@vueuse/motion": "^2.0.0",
"@pureadmin/descriptions": "^1.2.0",
"@pureadmin/table": "^3.1.2",
"@pureadmin/utils": "^2.4.5",
"@vueuse/core": "^10.9.0",
"@vueuse/motion": "^2.1.0",
"animate.css": "^4.1.1",
"axios": "^1.4.0",
"dayjs": "^1.11.8",
"echarts": "^5.4.2",
"element-plus": "2.3.6",
"axios": "^1.6.7",
"dayjs": "^1.11.10",
"echarts": "^5.5.0",
"element-plus": "^2.6.0",
"js-cookie": "^3.0.5",
"mitt": "^3.0.0",
"mockjs": "^1.1.0",
"localforage": "^1.10.0",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"path": "^0.12.7",
"pinia": "^2.1.4",
"pinyin-pro": "^3.15.2",
"pinia": "^2.1.7",
"pinyin-pro": "^3.19.6",
"qs": "^6.11.2",
"responsive-storage": "^2.2.0",
"sortablejs": "^1.15.0",
"vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-router": "^4.2.2",
"vue-types": "^5.1.0"
"sortablejs": "^1.15.2",
"vue": "^3.4.21",
"vue-i18n": "^9.10.1",
"vue-router": "^4.3.0",
"vue-tippy": "^6.4.1",
"vue-types": "^5.1.1"
},
"devDependencies": {
"@commitlint/cli": "^17.6.6",
"@commitlint/config-conventional": "^17.6.6",
"@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.2",
"@commitlint/types": "^18.6.1",
"@eslint/js": "^8.57.0",
"@faker-js/faker": "^8.4.1",
"@iconify-icons/ep": "^1.2.12",
"@iconify-icons/ri": "^1.2.9",
"@iconify-icons/ri": "^1.2.10",
"@iconify/vue": "^4.1.1",
"@intlify/unplugin-vue-i18n": "^0.11.0",
"@pureadmin/theme": "^3.1.0",
"@types/js-cookie": "^3.0.3",
"@types/mockjs": "^1.0.7",
"@types/node": "^20.3.1",
"@types/nprogress": "0.2.0",
"@types/qs": "^6.9.7",
"@types/sortablejs": "^1.15.1",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"@vitejs/plugin-vue": "^4.2.3",
"@vitejs/plugin-vue-jsx": "^3.0.1",
"@vue/eslint-config-prettier": "^7.1.0",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.14",
"@intlify/unplugin-vue-i18n": "^2.0.0",
"@pureadmin/theme": "^3.2.0",
"@types/gradient-string": "^1.1.5",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.11.24",
"@types/nprogress": "^0.2.3",
"@types/qs": "^6.9.12",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"autoprefixer": "^10.4.18",
"boxen": "^7.1.1",
"cloc": "^2.11.0",
"cssnano": "^6.0.1",
"eslint": "^8.43.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.15.1",
"husky": "^8.0.3",
"lint-staged": "^13.2.2",
"picocolors": "^1.0.0",
"postcss": "^8.4.24",
"postcss-html": "^1.5.0",
"postcss-import": "^15.1.0",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.8",
"pretty-quick": "^3.1.3",
"rimraf": "^5.0.1",
"rollup-plugin-visualizer": "^5.9.2",
"sass": "^1.63.6",
"sass-loader": "^13.3.2",
"stylelint": "^15.9.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^4.2.0",
"stylelint-config-recommended": "^12.0.0",
"stylelint-config-recommended-scss": "^12.0.0",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^33.0.0",
"stylelint-config-standard-scss": "^9.0.0",
"stylelint-order": "^6.0.3",
"stylelint-prettier": "^3.0.0",
"stylelint-scss": "^5.0.1",
"svgo": "^3.0.2",
"tailwindcss": "^3.3.2",
"terser": "^5.18.1",
"typescript": "5.0.4",
"vite": "^4.3.9",
"cssnano": "^6.0.5",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-define-config": "^2.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.22.0",
"gradient-string": "^2.0.2",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"postcss": "^8.4.35",
"postcss-html": "^1.6.0",
"postcss-import": "^16.0.1",
"postcss-scss": "^4.0.9",
"prettier": "^3.2.5",
"rimraf": "^5.0.5",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.71.1",
"stylelint": "^16.2.1",
"stylelint-config-recess-order": "^5.0.0",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard-scss": "^13.0.0",
"stylelint-prettier": "^5.0.0",
"svgo": "^3.2.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.5",
"vite-plugin-cdn-import": "^0.3.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-mock": "2.9.6",
"vite-plugin-remove-console": "^2.1.1",
"vite-svg-loader": "^4.0.0",
"vue-eslint-parser": "^9.3.1",
"vue-tsc": "^1.8.1"
"vite-plugin-fake-server": "^2.1.1",
"vite-plugin-remove-console": "^2.2.0",
"vite-plugin-router-warn": "^1.0.0",
"vite-svg-loader": "^5.1.0",
"vue-eslint-parser": "^9.4.2",
"vue-tsc": "^1.8.27"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
"rollup",
"webpack",
"core-js"
]
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0",
"pnpm": ">=8.6.10"
},
"packageManager": "pnpm@8.6.10",
"pnpm": {
"allowedDeprecatedVersions": {
"sourcemap-codec": "*",
"domexception": "*",
"w3c-hr-time": "*",
"stable": "*"
"stable": "*",
"abab": "*"
}
}
},
"repository": "git@github.com:pure-admin/pure-admin-thin.git",
"author": "xiaoxian521",
"license": "MIT"
}

10473
pnpm-lock.yaml
File diff suppressed because it is too large
View File

5
postcss.config.js

@ -1,4 +1,7 @@
module.exports = {
// @ts-check
/** @type {import('postcss-load-config').Config} */
export default {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},

2
public/logo.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path fill="#386BF3" d="M410.558.109c0 210.974-300.876 361.752-300.876 633.548 0 174.943 134.704 316.787 300.876 316.787s300.877-141.817 300.877-316.787C711.408 361.752 410.558 210.974 410.558.109z"/><path fill="#C3D2FB" d="M613.469 73.665c0 211.055-300.877 361.914-300.877 633.547C312.592 882.156 447.296 1024 613.47 1024s300.876-141.817 300.876-316.788C914.29 435.58 613.469 284.72 613.469 73.665z"/><path fill="#303F5B" d="M312.592 707.212c0-183.713 137.636-312.171 226.723-441.39 81.702 106.112 172.12 218.74 172.12 367.726A309.755 309.755 0 0 1 420.36 950.064a323.114 323.114 0 0 1-107.769-242.852z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path fill="#386BF3" d="M410.558.109c0 210.974-300.876 361.752-300.876 633.548 0 174.943 134.704 316.787 300.876 316.787s300.877-141.817 300.877-316.787C711.408 361.752 410.558 210.974 410.558.109"/><path fill="#C3D2FB" d="M613.469 73.665c0 211.055-300.877 361.914-300.877 633.547C312.592 882.156 447.296 1024 613.47 1024s300.876-141.817 300.876-316.788C914.29 435.58 613.469 284.72 613.469 73.665"/><path fill="#303F5B" d="M312.592 707.212c0-183.713 137.636-312.171 226.723-441.39 81.702 106.112 172.12 218.74 172.12 367.726A309.755 309.755 0 0 1 420.36 950.064a323.1 323.1 0 0 1-107.769-242.852z"/></svg>

11
public/serverConfig.json → public/platform-config.json

@ -1,5 +1,5 @@
{
"Version": "4.5.0",
"Version": "5.1.0",
"Title": "PureAdmin",
"FixedHeader": true,
"HiddenSideBar": false,
@ -7,17 +7,20 @@
"KeepAlive": true,
"Locale": "zh",
"Layout": "vertical",
"Theme": "default",
"Theme": "light",
"DarkMode": false,
"OverallStyle": "light",
"Grey": false,
"Weak": false,
"HideTabs": false,
"HideFooter": false,
"SidebarStatus": true,
"EpThemeColor": "#409EFF",
"ShowLogo": true,
"ShowModel": "smart",
"MenuArrowIconNoTransition": true,
"MenuArrowIconNoTransition": false,
"CachingAsyncRoutes": false,
"TooltipEffect": "light",
"ResponsiveStorageNameSpace": "responsive-"
"ResponsiveStorageNameSpace": "responsive-",
"MenuSearchHistory": 6
}

4
src/App.vue

@ -8,8 +8,8 @@
<script lang="ts">
import { defineComponent } from "vue";
import { ElConfigProvider } from "element-plus";
import zhCn from "element-plus/lib/locale/lang/zh-cn";
import en from "element-plus/lib/locale/lang/en";
import en from "element-plus/dist/locale/en.mjs";
import zhCn from "element-plus/dist/locale/zh-cn.mjs";
import { ReDialog } from "@/components/ReDialog";
export default defineComponent({
name: "app",

2
src/api/routes.ts

@ -6,5 +6,5 @@ type Result = {
};
export const getAsyncRoutes = () => {
return http.request<Result>("get", "/getAsyncRoutes");
return http.request<Result>("get", "/get-async-routes");
};

2
src/api/user.ts

@ -35,5 +35,5 @@ export const getLogin = (data?: object) => {
/** 刷新token */
export const refreshTokenApi = (data?: object) => {
return http.request<RefreshTokenResult>("post", "/refreshToken", { data });
return http.request<RefreshTokenResult>("post", "/refresh-token", { data });
};

3
src/assets/iconfont/iconfont.css

@ -1,6 +1,7 @@
@font-face {
font-family: "iconfont"; /* Project id 2208059 */
src: url("iconfont.woff2?t=1671895108120") format("woff2"),
src:
url("iconfont.woff2?t=1671895108120") format("woff2"),
url("iconfont.woff?t=1671895108120") format("woff"),
url("iconfont.ttf?t=1671895108120") format("truetype");
}

2
src/assets/login/avatar.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path fill="#386BF3" d="M410.558.109c0 210.974-300.876 361.752-300.876 633.548 0 174.943 134.704 316.787 300.876 316.787s300.877-141.817 300.877-316.787C711.408 361.752 410.558 210.974 410.558.109z"/><path fill="#C3D2FB" d="M613.469 73.665c0 211.055-300.877 361.914-300.877 633.547C312.592 882.156 447.296 1024 613.47 1024s300.876-141.817 300.876-316.788C914.29 435.58 613.469 284.72 613.469 73.665z"/><path fill="#303F5B" d="M312.592 707.212c0-183.713 137.636-312.171 226.723-441.39 81.702 106.112 172.12 218.74 172.12 367.726A309.755 309.755 0 0 1 420.36 950.064a323.114 323.114 0 0 1-107.769-242.852z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" class="icon" viewBox="0 0 1024 1024"><path fill="#386BF3" d="M410.558.109c0 210.974-300.876 361.752-300.876 633.548 0 174.943 134.704 316.787 300.876 316.787s300.877-141.817 300.877-316.787C711.408 361.752 410.558 210.974 410.558.109"/><path fill="#C3D2FB" d="M613.469 73.665c0 211.055-300.877 361.914-300.877 633.547C312.592 882.156 447.296 1024 613.47 1024s300.876-141.817 300.876-316.788C914.29 435.58 613.469 284.72 613.469 73.665"/><path fill="#303F5B" d="M312.592 707.212c0-183.713 137.636-312.171 226.723-441.39 81.702 106.112 172.12 218.74 172.12 367.726A309.755 309.755 0 0 1 420.36 950.064a323.1 323.1 0 0 1-107.769-242.852z"/></svg>

2
src/assets/login/illustration.svg
File diff suppressed because it is too large
View File

2
src/assets/status/403.svg
File diff suppressed because it is too large
View File

2
src/assets/status/404.svg
File diff suppressed because it is too large
View File

2
src/assets/status/500.svg
File diff suppressed because it is too large
View File

2
src/assets/svg/back_top.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M2.88 18.054a35.897 35.897 0 0 1 8.531-16.32.8.8 0 0 1 1.178 0c.166.18.304.332.413.455a35.897 35.897 0 0 1 8.118 15.865c-2.141.451-4.34.747-6.584.874l-2.089 4.178a.5.5 0 0 1-.894 0l-2.089-4.178a44.019 44.019 0 0 1-6.584-.874zm6.698-1.123 1.157.066L12 19.527l1.265-2.53 1.157-.066a42.137 42.137 0 0 0 4.227-.454A33.913 33.913 0 0 0 12 4.09a33.913 33.913 0 0 0-6.649 12.387c1.395.222 2.805.374 4.227.454zM12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M2.88 18.054a35.9 35.9 0 0 1 8.531-16.32.8.8 0 0 1 1.178 0q.25.27.413.455a35.9 35.9 0 0 1 8.118 15.865c-2.141.451-4.34.747-6.584.874l-2.089 4.178a.5.5 0 0 1-.894 0l-2.089-4.178a44 44 0 0 1-6.584-.874m6.698-1.123 1.157.066L12 19.527l1.265-2.53 1.157-.066a42 42 0 0 0 4.227-.454A33.9 33.9 0 0 0 12 4.09a33.9 33.9 0 0 0-6.649 12.387q2.093.334 4.227.454M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/></svg>

2
src/assets/svg/dark.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M11.38 2.019a7.5 7.5 0 1 0 10.6 10.6C21.662 17.854 17.316 22 12.001 22 6.477 22 2 17.523 2 12c0-5.315 4.146-9.661 9.38-9.981z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M11.38 2.019a7.5 7.5 0 1 0 10.6 10.6C21.662 17.854 17.316 22 12.001 22 6.477 22 2 17.523 2 12c0-5.315 4.146-9.661 9.38-9.981"/></svg>

2
src/assets/svg/day.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 18a6 6 0 1 1 0-12 6 6 0 0 1 0 12zM11 1h2v3h-2V1zm0 19h2v3h-2v-3zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05 3.515 4.93zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414-2.121-2.121zm2.121-14.85 1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414 2.121-2.121zM23 11v2h-3v-2h3zM4 11v2H1v-2h3z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 18a6 6 0 1 1 0-12 6 6 0 0 1 0 12M11 1h2v3h-2zm0 19h2v3h-2zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414zm2.121-14.85 1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414zM23 11v2h-3v-2zM4 11v2H1v-2z"/></svg>

2
src/assets/svg/enter_outlined.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" aria-hidden="true" class="iconify iconify--ant-design" viewBox="0 0 1024 1024"><path fill="currentColor" d="M864 170h-60c-4.4 0-8 3.6-8 8v518H310v-73c0-6.7-7.8-10.5-13-6.3l-141.9 112a8 8 0 0 0 0 12.6l141.9 112c5.3 4.2 13 .4 13-6.3v-75h498c35.3 0 64-28.7 64-64V178c0-4.4-3.6-8-8-8z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" aria-hidden="true" class="iconify iconify--ant-design" viewBox="0 0 1024 1024"><path fill="currentColor" d="M864 170h-60c-4.4 0-8 3.6-8 8v518H310v-73c0-6.7-7.8-10.5-13-6.3l-141.9 112a8 8 0 0 0 0 12.6l141.9 112c5.3 4.2 13 .4 13-6.3v-75h498c35.3 0 64-28.7 64-64V178c0-4.4-3.6-8-8-8"/></svg>

2
src/assets/svg/exit_screen.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" aria-hidden="true" class="re-screen" color="#00000073" viewBox="0 0 16 16"><path fill="currentColor" d="M3.5 4H1V3h2V1h1v2.5l-.5.5zM13 3V1h-1v2.5l.5.5H15V3h-2zm-1 9.5V15h1v-2h2v-1h-2.5l-.5.5zM1 12v1h2v2h1v-2.5l-.5-.5H1zm11-1.5-.5.5h-7l-.5-.5v-5l.5-.5h7l.5.5v5zM10 7H6v2h4V7z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" aria-hidden="true" class="re-screen" color="#00000073" viewBox="0 0 16 16"><path fill="currentColor" d="M3.5 4H1V3h2V1h1v2.5zM13 3V1h-1v2.5l.5.5H15V3zm-1 9.5V15h1v-2h2v-1h-2.5zM1 12v1h2v2h1v-2.5l-.5-.5zm11-1.5-.5.5h-7l-.5-.5v-5l.5-.5h7l.5.5zM10 7H6v2h4z"/></svg>

2
src/assets/svg/full_screen.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" aria-hidden="true" class="re-screen" color="#00000073" viewBox="0 0 16 16"><path fill="currentColor" d="M3 12h10V4H3v8zm2-6h6v4H5V6zM2 6H1V2.5l.5-.5H5v1H2v3zm13-3.5V6h-1V3h-3V2h3.5l.5.5zM14 10h1v3.5l-.5.5H11v-1h3v-3zM2 13h3v1H1.5l-.5-.5V10h1v3z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" aria-hidden="true" class="re-screen" color="#00000073" viewBox="0 0 16 16"><path fill="currentColor" d="M3 12h10V4H3zm2-6h6v4H5zM2 6H1V2.5l.5-.5H5v1H2zm13-3.5V6h-1V3h-3V2h3.5zM14 10h1v3.5l-.5.5H11v-1h3zM2 13h3v1H1.5l-.5-.5V10h1z"/></svg>

2
src/assets/svg/keyboard_esc.svg

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" aria-hidden="true" class="iconify iconify--mdi" viewBox="0 0 24 24"><path fill="currentColor" d="M1 7h6v2H3v2h4v2H3v2h4v2H1V7m10 0h4v2h-4v2h2a2 2 0 0 1 2 2v2c0 1.11-.89 2-2 2H9v-2h4v-2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2m8 0h2a2 2 0 0 1 2 2v1h-2V9h-2v6h2v-1h2v1c0 1.11-.89 2-2 2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" aria-hidden="true" class="iconify iconify--mdi" viewBox="0 0 24 24"><path fill="currentColor" d="M1 7h6v2H3v2h4v2H3v2h4v2H1zm10 0h4v2h-4v2h2a2 2 0 0 1 2 2v2c0 1.11-.89 2-2 2H9v-2h4v-2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2m8 0h2a2 2 0 0 1 2 2v1h-2V9h-2v6h2v-1h2v1c0 1.11-.89 2-2 2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2"/></svg>

1
src/assets/svg/system.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="icon" viewBox="0 0 1024 1024"><path d="M554 849.574c0 23.365-18.635 42.307-42 42.307s-42-18.941-42-42.307V662.719c0-23.365 18.635-42.307 42-42.307v-7.051c23.365 0 42 25.993 42 49.358z"/><path d="M893 888.5c0 17.397-14.103 31.5-31.5 31.5h-700c-17.397 0-31.5-14.103-31.5-31.5s14.103-31.5 31.5-31.5h700c17.397 0 31.5 14.103 31.5 31.5m33-714.074C926 135.484 894.686 105 855.744 105H168.256C129.314 105 98 135.484 98 174.426V533h828zM98 630.988C98 669.931 129.314 702 168.256 702h687.488C894.686 702 926 669.931 926 630.988V596H98z"/></svg>

7
src/components/ReDialog/index.ts

@ -27,8 +27,11 @@ const addDialog = (options: DialogOptions) => {
/** 关闭弹框 */
const closeDialog = (options: DialogOptions, index: number, args?: any) => {
dialogStore.value.splice(index, 1);
dialogStore.value[index].visible = false;
options.closeCallBack && options.closeCallBack({ options, index, args });
useTimeoutFn(() => {
dialogStore.value.splice(index, 1);
}, 200);
};
/**
@ -49,7 +52,7 @@ const closeAllDialog = () => {
/** 使`addDialog`
* https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L4
* https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L13
* https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L18
* https://github.com/pure-admin/vue-pure-admin/blob/main/src/App.vue#L20
*/
const ReDialog = withInstall(reDialog);

6
src/components/ReDialog/index.vue

@ -84,13 +84,13 @@ function handleClose(
<template>
<el-dialog
class="pure-dialog"
v-for="(options, index) in dialogStore"
:key="index"
v-bind="options"
v-model="options.visible"
class="pure-dialog"
:fullscreen="fullscreen ? true : options?.fullscreen ? true : false"
@close="handleClose(options, index)"
@closed="handleClose(options, index)"
@opened="eventsCallBack('open', options, index)"
@openAutoFocus="eventsCallBack('openAutoFocus', options, index)"
@closeAutoFocus="eventsCallBack('closeAutoFocus', options, index)"
@ -123,8 +123,8 @@ function handleClose(
</i>
</div>
<component
v-else
:is="options?.headerRenderer({ close, titleId, titleClass })"
v-else
/>
</template>
<component

6
src/components/ReDialog/type.ts

@ -3,7 +3,7 @@ import type { CSSProperties, VNode, Component } from "vue";
type DoneFn = (cancel?: boolean) => void;
type EventType = "open" | "close" | "openAutoFocus" | "closeAutoFocus";
type ArgsType = {
/** `cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页 */
/** `cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页或按下了esc键 */
command: "cancel" | "sure" | "close";
};
@ -157,7 +157,7 @@ interface DialogOptions extends DialogProps {
options: DialogOptions;
index: number;
}) => void;
/** `Dialog` 关闭后的回调(只有点击右上角关闭按钮或空白页关闭页面时才会触发) */
/** `Dialog` 关闭后的回调(只有点击右上角关闭按钮或空白页或按下了esc键关闭页面时才会触发) */
close?: ({
options,
index
@ -165,7 +165,7 @@ interface DialogOptions extends DialogProps {
options: DialogOptions;
index: number;
}) => void;
/** `Dialog` 关闭后的回调。 `args` 返回的 `command` 值解析:`cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页 */
/** `Dialog` 关闭后的回调。 `args` 返回的 `command` 值解析:`cancel` 点击取消按钮、`sure` 点击确定按钮、`close` 点击右上角关闭按钮或空白页或按下了esc键 */
closeCallBack?: ({
options,
index,

6
src/components/ReIcon/src/hooks.ts

@ -1,5 +1,5 @@
import { iconType } from "./types";
import { h, defineComponent, Component } from "vue";
import type { iconType } from "./types";
import { h, defineComponent, type Component } from "vue";
import { IconifyIconOnline, IconifyIconOffline, FontIcon } from "../index";
/**
@ -33,7 +33,7 @@ export function useRenderIcon(icon: any, attrs?: iconType): Component {
});
} else if (typeof icon === "function" || typeof icon?.render === "function") {
// svg
return icon;
return attrs ? h(icon, { ...attrs }) : icon;
} else if (typeof icon === "object") {
return defineComponent({
name: "OfflineIcon",

2
src/components/ReIcon/src/iconifyIconOffline.ts

@ -1,7 +1,7 @@
import { h, defineComponent } from "vue";
import { Icon as IconifyIcon, addIcon } from "@iconify/vue/dist/offline";
// Iconify Icon在Vue里本地使用(用于内网环境)https://docs.iconify.design/icon-components/vue/offline.html
// Iconify Icon在Vue里本地使用(用于内网环境)
export default defineComponent({
name: "IconifyIconOffline",
components: { IconifyIcon },

18
src/components/ReIcon/src/offlineIcon.ts

@ -1,14 +1,14 @@
// 这里存放本地图标,在 src/layout/index.vue 文件中加载,避免在首启动加载
import { addIcon } from "@iconify/vue/dist/offline";
/**
* src/layout/index.vue
*/
// 本地菜单图标,后端在路由的 icon 中返回对应的图标字符串并且前端在此处使用 addIcon 添加即可渲染菜单图标
// @iconify-icons/ep
import Lollipop from "@iconify-icons/ep/lollipop";
import HomeFilled from "@iconify-icons/ep/home-filled";
addIcon("ep:lollipop", Lollipop);
addIcon("ep:home-filled", HomeFilled);
// @iconify-icons/ri
import Search from "@iconify-icons/ri/search-line";
import InformationLine from "@iconify-icons/ri/information-line";
import Lollipop from "@iconify-icons/ep/lollipop";
addIcon("homeFilled", HomeFilled);
addIcon("informationLine", InformationLine);
addIcon("lollipop", Lollipop);
addIcon("ri:search-line", Search);
addIcon("ri:information-line", InformationLine);

3
src/components/ReIcon/src/types.ts

@ -13,7 +13,8 @@ export interface iconType {
align?: string;
onLoad?: Function;
includes?: Function;
// svg 需要什么SVG属性自行添加
fill?: string;
// all icon
style?: object;
}

65
src/components/RePureTableBar/src/bar.tsx

@ -29,6 +29,10 @@ const props = {
columns: {
type: Array as PropType<TableColumnList>,
default: () => []
},
isExpandAll: {
type: Boolean,
default: true
}
};
@ -37,12 +41,11 @@ export default defineComponent({
props,
emits: ["refresh"],
setup(props, { emit, slots, attrs }) {
const buttonRef = ref();
const size = ref("default");
const isExpandAll = ref(true);
const loading = ref(false);
const checkAll = ref(true);
const isIndeterminate = ref(false);
const isExpandAll = ref(props.isExpandAll);
const filterColumns = cloneDeep(props?.columns).filter(column =>
isBoolean(column?.hide)
? !column.hide
@ -200,11 +203,22 @@ export default defineComponent({
: false;
};
const rendTippyProps = (content: string) => {
// https://vue-tippy.netlify.app/props
return {
content,
offset: [0, 18],
duration: [300, 0],
followCursor: true,
hideOnClick: "toggle"
};
};
const reference = {
reference: () => (
<SettingIcon
class={["w-[16px]", iconClass.value]}
onMouseover={e => (buttonRef.value = e.currentTarget)}
v-tippy={rendTippyProps("列设置")}
/>
)
};
@ -224,45 +238,43 @@ export default defineComponent({
) : null}
{props.tableRef?.size ? (
<>
<el-tooltip
effect="dark"
content={isExpandAll.value ? "折叠" : "展开"}
placement="top"
>
<ExpandIcon
class={["w-[16px]", iconClass.value]}
style={{
transform: isExpandAll.value ? "none" : "rotate(-90deg)"
}}
v-tippy={rendTippyProps(
isExpandAll.value ? "折叠" : "展开"
)}
onClick={() => onExpand()}
/>
</el-tooltip>
<el-divider direction="vertical" />
</>
) : null}
<el-tooltip effect="dark" content="刷新" placement="top">
<RefreshIcon
class={[
"w-[16px]",
iconClass.value,
loading.value ? "animate-spin" : ""
]}
v-tippy={rendTippyProps("刷新")}
onClick={() => onReFresh()}
/>
</el-tooltip>
<el-divider direction="vertical" />
<el-tooltip effect="dark" content="密度" placement="top">
<el-dropdown v-slots={dropdown} trigger="click">
<el-dropdown
v-slots={dropdown}
trigger="click"
v-tippy={rendTippyProps("密度")}
>
<CollapseIcon class={["w-[16px]", iconClass.value]} />
</el-dropdown>
</el-tooltip>
<el-divider direction="vertical" />
<el-popover
v-slots={reference}
placement="bottom-start"
popper-style={{ padding: 0 }}
width="160"
width="200"
trigger="click"
>
<div class={[topClass.value]}>
@ -279,6 +291,7 @@ export default defineComponent({
</div>
<div class="pt-[6px] pl-[11px]">
<el-scrollbar max-height="36vh">
<el-checkbox-group
v-model={checkedColumns.value}
onChange={value => handleCheckedColumnsChange(value)}
@ -304,7 +317,7 @@ export default defineComponent({
/>
<el-checkbox
key={item}
label={item}
value={item}
onChange={value =>
handleCheckColumnListChange(value, item)
}
@ -321,28 +334,10 @@ export default defineComponent({
})}
</el-space>
</el-checkbox-group>
</el-scrollbar>
</div>
</el-popover>
</div>
<el-tooltip
popper-options={{
modifiers: [
{
name: "computeStyles",
options: {
adaptive: false,
enabled: false
}
}
]
}}
placement="top"
virtual-ref={buttonRef.value}
virtual-triggering
trigger="hover"
content="列设置"
/>
</div>
{slots.default({
size: size.value,

2
src/components/RePureTableBar/src/svg/collapse.svg

@ -1 +1 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M13.79 10.21a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42l-2.5-2.5a1 1 0 0 0-.33-.21 1 1 0 0 0-.76 0 1 1 0 0 0-.33.21l-2.5 2.5a1 1 0 0 0 1.42 1.42l.79-.8v5.18l-.79-.8a1 1 0 0 0-1.42 1.42l2.5 2.5a1 1 0 0 0 .33.21.94.94 0 0 0 .76 0 1 1 0 0 0 .33-.21l2.5-2.5a1 1 0 0 0-1.42-1.42l-.79.8V9.41ZM7 4h10a1 1 0 0 0 0-2H7a1 1 0 0 0 0 2Zm10 16H7a1 1 0 0 0 0 2h10a1 1 0 0 0 0-2Z"/></svg>
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M13.79 10.21a1 1 0 0 0 1.42 0 1 1 0 0 0 0-1.42l-2.5-2.5a1 1 0 0 0-.33-.21 1 1 0 0 0-.76 0 1 1 0 0 0-.33.21l-2.5 2.5a1 1 0 0 0 1.42 1.42l.79-.8v5.18l-.79-.8a1 1 0 0 0-1.42 1.42l2.5 2.5a1 1 0 0 0 .33.21.94.94 0 0 0 .76 0 1 1 0 0 0 .33-.21l2.5-2.5a1 1 0 0 0-1.42-1.42l-.79.8V9.41ZM7 4h10a1 1 0 0 0 0-2H7a1 1 0 0 0 0 2m10 16H7a1 1 0 0 0 0 2h10a1 1 0 0 0 0-2"/></svg>

2
src/components/RePureTableBar/src/svg/drag.svg

@ -1 +1 @@
<svg width="32" height="32" fill="currentColor" aria-hidden="true" data-icon="holder" viewBox="64 64 896 896"><path d="M300 276.5a56 56 0 1 0 56-97 56 56 0 0 0-56 97zm0 284a56 56 0 1 0 56-97 56 56 0 0 0-56 97zM640 228a56 56 0 1 0 112 0 56 56 0 0 0-112 0zm0 284a56 56 0 1 0 112 0 56 56 0 0 0-112 0zM300 844.5a56 56 0 1 0 56-97 56 56 0 0 0-56 97zM640 796a56 56 0 1 0 112 0 56 56 0 0 0-112 0z"/></svg>
<svg width="32" height="32" fill="currentColor" aria-hidden="true" data-icon="holder" viewBox="64 64 896 896"><path d="M300 276.5a56 56 0 1 0 56-97 56 56 0 0 0-56 97m0 284a56 56 0 1 0 56-97 56 56 0 0 0-56 97M640 228a56 56 0 1 0 112 0 56 56 0 0 0-112 0m0 284a56 56 0 1 0 112 0 56 56 0 0 0-112 0M300 844.5a56 56 0 1 0 56-97 56 56 0 0 0-56 97M640 796a56 56 0 1 0 112 0 56 56 0 0 0-112 0"/></svg>

2
src/components/RePureTableBar/src/svg/expand.svg

@ -1 +1 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M22 4V2H2v2h9v14.17l-5.5-5.5-1.42 1.41L12 22l7.92-7.92-1.42-1.41-5.5 5.5V4h9Z"/></svg>
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M22 4V2H2v2h9v14.17l-5.5-5.5-1.42 1.41L12 22l7.92-7.92-1.42-1.41-5.5 5.5V4z"/></svg>

2
src/components/RePureTableBar/src/svg/settings.svg

@ -1 +1 @@
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M3.34 17a10.018 10.018 0 0 1-.978-2.326 3 3 0 0 0 .002-5.347A9.99 9.99 0 0 1 4.865 4.99a3 3 0 0 0 4.631-2.674 9.99 9.99 0 0 1 5.007.002 3 3 0 0 0 4.632 2.672A9.99 9.99 0 0 1 20.66 7c.433.749.757 1.53.978 2.326a3 3 0 0 0-.002 5.347 9.99 9.99 0 0 1-2.501 4.337 3 3 0 0 0-4.631 2.674 9.99 9.99 0 0 1-5.007-.002 3 3 0 0 0-4.632-2.672A10.018 10.018 0 0 1 3.34 17zm5.66.196a4.993 4.993 0 0 1 2.25 2.77c.499.047 1 .048 1.499.001A4.993 4.993 0 0 1 15 17.197a4.993 4.993 0 0 1 3.525-.565c.29-.408.54-.843.748-1.298A4.993 4.993 0 0 1 18 12c0-1.26.47-2.437 1.273-3.334a8.126 8.126 0 0 0-.75-1.298A4.993 4.993 0 0 1 15 6.804a4.993 4.993 0 0 1-2.25-2.77c-.499-.047-1-.048-1.499-.001A4.993 4.993 0 0 1 9 6.803a4.993 4.993 0 0 1-3.525.565 7.99 7.99 0 0 0-.748 1.298A4.993 4.993 0 0 1 6 12a4.99 4.99 0 0 1-1.273 3.334 8.126 8.126 0 0 0 .75 1.298A4.993 4.993 0 0 1 9 17.196zM12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/></svg>
<svg width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M3.34 17a10 10 0 0 1-.978-2.326 3 3 0 0 0 .002-5.347A10 10 0 0 1 4.865 4.99a3 3 0 0 0 4.631-2.674 10 10 0 0 1 5.007.002 3 3 0 0 0 4.632 2.672A10 10 0 0 1 20.66 7c.433.749.757 1.53.978 2.326a3 3 0 0 0-.002 5.347 10 10 0 0 1-2.501 4.337 3 3 0 0 0-4.631 2.674 10 10 0 0 1-5.007-.002 3 3 0 0 0-4.632-2.672A10 10 0 0 1 3.34 17m5.66.196a5 5 0 0 1 2.25 2.77q.75.071 1.499.001A5 5 0 0 1 15 17.197a5 5 0 0 1 3.525-.565q.435-.614.748-1.298A5 5 0 0 1 18 12c0-1.26.47-2.437 1.273-3.334a8 8 0 0 0-.75-1.298A5 5 0 0 1 15 6.804a5 5 0 0 1-2.25-2.77q-.75-.071-1.499-.001A5 5 0 0 1 9 6.803a5 5 0 0 1-3.525.565 8 8 0 0 0-.748 1.298A5 5 0 0 1 6 12a5 5 0 0 1-1.273 3.334 8 8 0 0 0 .75 1.298A5 5 0 0 1 9 17.196M12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/></svg>

8
src/components/ReSegmented/index.ts

@ -0,0 +1,8 @@
import reSegmented from "./src/index";
import { withInstall } from "@pureadmin/utils";
/** 分段控制器组件 */
export const ReSegmented = withInstall(reSegmented);
export default ReSegmented;
export type { OptionsType } from "./src/type";

80
src/components/ReSegmented/src/index.css

@ -0,0 +1,80 @@
.pure-segmented {
box-sizing: border-box;
display: inline-block;
padding: 2px;
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
background-color: rgb(0 0 0 / 4%);
border-radius: 2px;
}
.pure-segmented-group {
position: relative;
display: flex;
align-items: stretch;
justify-items: flex-start;
width: 100%;
}
.pure-segmented-item-selected {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
display: none;
width: 0;
height: 100%;
padding: 4px 0;
background-color: #fff;
border-radius: 4px;
box-shadow:
0 2px 8px -2px rgb(0 0 0 / 5%),
0 1px 4px -1px rgb(0 0 0 / 7%),
0 0 1px rgb(0 0 0 / 7%);
transition:
transform 0.5s cubic-bezier(0.645, 0.045, 0.355, 1),
width 0.5s cubic-bezier(0.645, 0.045, 0.355, 1);
will-change: transform, width;
}
.pure-segmented-item {
position: relative;
text-align: center;
cursor: pointer;
border-radius: 4px;
transition: all 0.1s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.pure-segmented-item > div {
min-height: 28px;
line-height: 28px;
padding: 0 11px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.pure-segmented-item > input {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.pure-segmented-item-label {
display: flex;
align-items: center;
}
.pure-segmented-item-icon svg {
width: 16px;
height: 16px;
}
.pure-segmented-item-disabled {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}

166
src/components/ReSegmented/src/index.tsx

@ -0,0 +1,166 @@
import "./index.css";
import {
h,
ref,
toRef,
watch,
nextTick,
defineComponent,
getCurrentInstance
} from "vue";
import type { OptionsType } from "./type";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { isFunction, isNumber, useDark } from "@pureadmin/utils";
const props = {
options: {
type: Array<OptionsType>,
default: () => []
},
/** 默认选中,按照第一个索引为 `0` 的模式,可选(`modelValue`只有传`number`类型时才为响应式) */
modelValue: {
type: undefined,
require: false,
default: "0"
}
};
export default defineComponent({
name: "ReSegmented",
props,
emits: ["change", "update:modelValue"],
setup(props, { emit }) {
const width = ref(0);
const translateX = ref(0);
const { isDark } = useDark();
const initStatus = ref(false);
const curMouseActive = ref(-1);
const segmentedItembg = ref("");
const instance = getCurrentInstance()!;
const curIndex = isNumber(props.modelValue)
? toRef(props, "modelValue")
: ref(0);
function handleChange({ option, index }, event: Event) {
if (option.disabled) return;
event.preventDefault();
isNumber(props.modelValue)
? emit("update:modelValue", index)
: (curIndex.value = index);
segmentedItembg.value = "";
emit("change", { index, option });
}
function handleMouseenter({ option, index }, event: Event) {
event.preventDefault();
curMouseActive.value = index;
if (option.disabled || curIndex.value === index) {
segmentedItembg.value = "";
} else {
segmentedItembg.value = isDark.value
? "#1f1f1f"
: "rgba(0, 0, 0, 0.06)";
}
}
function handleMouseleave(_, event: Event) {
event.preventDefault();
curMouseActive.value = -1;
}
function handleInit(index = curIndex.value) {
nextTick(() => {
const curLabelRef = instance?.proxy?.$refs[`labelRef${index}`] as ElRef;
width.value = curLabelRef.clientWidth;
translateX.value = curLabelRef.offsetLeft;
initStatus.value = true;
});
}
watch(
() => curIndex.value,
index => {
nextTick(() => {
handleInit(index);
});
},
{
deep: true,
immediate: true
}
);
const rendLabel = () => {
return props.options.map((option, index) => {
return (
<label
ref={`labelRef${index}`}
class={[
"pure-segmented-item",
option?.disabled && "pure-segmented-item-disabled"
]}
style={{
background:
curMouseActive.value === index ? segmentedItembg.value : "",
color:
!option.disabled &&
(curIndex.value === index || curMouseActive.value === index)
? isDark.value
? "rgba(255, 255, 255, 0.85)"
: "rgba(0,0,0,.88)"
: ""
}}
onMouseenter={event => handleMouseenter({ option, index }, event)}
onMouseleave={event => handleMouseleave({ option, index }, event)}
onClick={event => handleChange({ option, index }, event)}
>
<input type="radio" name="segmented" />
<div
class="pure-segmented-item-label"
v-tippy={{
content: option?.tip,
zIndex: 41000
}}
>
{option.icon && !isFunction(option.label) ? (
<span
class="pure-segmented-item-icon"
style={{ marginRight: option.label ? "6px" : 0 }}
>
{h(
useRenderIcon(option.icon, {
...option?.iconAttrs
})
)}
</span>
) : null}
{option.label ? (
isFunction(option.label) ? (
h(option.label)
) : (
<span>{option.label}</span>
)
) : null}
</div>
</label>
);
});
};
return () => (
<div class="pure-segmented">
<div class="pure-segmented-group">
<div
class="pure-segmented-item-selected"
style={{
width: `${width.value}px`,
transform: `translateX(${translateX.value}px)`,
display: initStatus.value ? "block" : "none"
}}
></div>
{rendLabel()}
</div>
</div>
);
}
});

20
src/components/ReSegmented/src/type.ts

@ -0,0 +1,20 @@
import type { VNode, Component } from "vue";
import type { iconType } from "@/components/ReIcon/src/types.ts";
export interface OptionsType {
/** 文字 */
label?: string | (() => VNode | Component);
/**
* @description `useRenderIcon`
* @see {@link https://yiming_chang.gitee.io/pure-admin-doc/pages/icon/#%E9%80%9A%E7%94%A8%E5%9B%BE%E6%A0%87-userendericon-hooks }
*/
icon?: string | Component;
/** 图标属性、样式配置 */
iconAttrs?: iconType;
/** 值 */
value?: any;
/** 是否禁用 */
disabled?: boolean;
/** `tooltip` 提示 */
tip?: string;
}

7
src/components/ReText/index.ts

@ -0,0 +1,7 @@
import reText from "./src/index.vue";
import { withInstall } from "@pureadmin/utils";
/** 支持`Tooltip`提示的文本省略组件 */
export const ReText = withInstall(reText);
export default ReText;

62
src/components/ReText/src/index.vue

@ -0,0 +1,62 @@
<script lang="ts" setup>
import { h, onMounted, ref, useSlots } from "vue";
import { useTippy, type TippyOptions } from "vue-tippy";
const props = defineProps({
//
lineClamp: {
type: [String, Number]
},
tippyProps: {
type: Object as PropType<TippyOptions>,
default: () => ({})
}
});
const $slots = useSlots();
const textRef = ref();
const tippyFunc = ref();
const isTextEllipsis = (el: HTMLElement) => {
if (!props.lineClamp) {
//
return el.scrollWidth > el.clientWidth;
} else {
//
return el.scrollHeight > el.clientHeight;
}
};
const getTippyProps = () => ({
content: h($slots.content || $slots.default),
...props.tippyProps
});
function handleHover(event: MouseEvent) {
if (isTextEllipsis(event.target as HTMLElement)) {
tippyFunc.value.setProps(getTippyProps());
tippyFunc.value.enable();
} else {
tippyFunc.value.disable();
}
}
onMounted(() => {
tippyFunc.value = useTippy(textRef.value?.$el, getTippyProps());
});
</script>
<template>
<el-text
v-bind="{
truncated: !lineClamp,
lineClamp,
...$attrs
}"
ref="textRef"
@mouseover.self="handleHover"
>
<slot />
</el-text>
</template>

10
src/config/index.ts

@ -1,4 +1,4 @@
import { App } from "vue";
import type { App } from "vue";
import axios from "axios";
let config: object = {};
@ -8,7 +8,7 @@ const setConfig = (cfg?: unknown) => {
config = Object.assign(config, cfg);
};
const getConfig = (key?: string): ServerConfigs => {
const getConfig = (key?: string): PlatformConfigs => {
if (typeof key === "string") {
const arr = key.split(".");
if (arr && arr.length) {
@ -27,11 +27,11 @@ const getConfig = (key?: string): ServerConfigs => {
};
/** 获取项目动态全局配置 */
export const getServerConfig = async (app: App): Promise<undefined> => {
export const getPlatformConfig = async (app: App): Promise<undefined> => {
app.config.globalProperties.$config = getConfig();
return axios({
method: "get",
url: `${VITE_PUBLIC_PATH}serverConfig.json`
url: `${VITE_PUBLIC_PATH}platform-config.json`
})
.then(({ data: config }) => {
let $config = app.config.globalProperties.$config;
@ -45,7 +45,7 @@ export const getServerConfig = async (app: App): Promise<undefined> => {
return $config;
})
.catch(() => {
throw "请在public文件夹下添加serverConfig.json配置文件";
throw "请在public文件夹下添加platform-config.json配置文件";
});
};

1
src/directives/index.ts

@ -2,3 +2,4 @@ export * from "./auth";
export * from "./copy";
export * from "./longpress";
export * from "./optimize";
export * from "./ripple";

48
src/directives/ripple/index.scss

@ -0,0 +1,48 @@
/* stylelint-disable-next-line scss/dollar-variable-colon-space-after */
$ripple-animation-transition-in:
transform 0.4s cubic-bezier(0, 0, 0.2, 1),
opacity 0.2s cubic-bezier(0, 0, 0.2, 1) !default;
$ripple-animation-transition-out: opacity 0.5s cubic-bezier(0, 0, 0.2, 1) !default;
$ripple-animation-visible-opacity: 0.25 !default;
.v-ripple {
&__container {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
border-radius: inherit;
contain: strict;
}
&__animation {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
pointer-events: none;
background: currentcolor;
border-radius: 50%;
opacity: 0;
will-change: transform, opacity;
&--enter {
opacity: 0;
transition: none;
}
&--in {
opacity: $ripple-animation-visible-opacity;
transition: $ripple-animation-transition-in;
}
&--out {
opacity: 0;
transition: $ripple-animation-transition-out;
}
}
}

234
src/directives/ripple/index.ts

@ -0,0 +1,234 @@
import "./index.scss";
import { isObject } from "@pureadmin/utils";
import type { Directive, DirectiveBinding } from "vue";
interface RippleOptions {
class?: string;
center?: boolean;
circle?: boolean;
}
export interface RippleDirectiveBinding
extends Omit<DirectiveBinding, "modifiers" | "value"> {
value?: boolean | { class: string };
modifiers: {
center?: boolean;
circle?: boolean;
};
}
function transform(el: HTMLElement, value: string) {
el.style.transform = value;
el.style.webkitTransform = value;
}
const calculate = (
e: PointerEvent,
el: HTMLElement,
value: RippleOptions = {}
) => {
const offset = el.getBoundingClientRect();
// 获取点击位置距离 el 的垂直和水平距离
let localX = e.clientX - offset.left;
let localY = e.clientY - offset.top;
let radius = 0;
let scale = 0.3;
// 计算点击位置到 el 顶点最远距离,即为圆的最大半径(勾股定理)
if (el._ripple?.circle) {
scale = 0.15;
radius = el.clientWidth / 2;
radius = value.center
? radius
: radius + Math.sqrt((localX - radius) ** 2 + (localY - radius) ** 2) / 4;
} else {
radius = Math.sqrt(el.clientWidth ** 2 + el.clientHeight ** 2) / 2;
}
// 中心点坐标
const centerX = `${(el.clientWidth - radius * 2) / 2}px`;
const centerY = `${(el.clientHeight - radius * 2) / 2}px`;
// 点击位置坐标
const x = value.center ? centerX : `${localX - radius}px`;
const y = value.center ? centerY : `${localY - radius}px`;
return { radius, scale, x, y, centerX, centerY };
};
const ripples = {
show(e: PointerEvent, el: HTMLElement, value: RippleOptions = {}) {
if (!el?._ripple?.enabled) {
return;
}
// 创建 ripple 元素和 ripple 父元素
const container = document.createElement("span");
const animation = document.createElement("span");
container.appendChild(animation);
container.className = "v-ripple__container";
if (value.class) {
container.className += ` ${value.class}`;
}
const { radius, scale, x, y, centerX, centerY } = calculate(e, el, value);
// ripple 圆大小
const size = `${radius * 2}px`;
animation.className = "v-ripple__animation";
animation.style.width = size;
animation.style.height = size;
el.appendChild(container);
// 获取目标元素样式表
const computed = window.getComputedStyle(el);
// 防止 position 被覆盖导致 ripple 位置有问题
if (computed && computed.position === "static") {
el.style.position = "relative";
el.dataset.previousPosition = "static";
}
animation.classList.add("v-ripple__animation--enter");
animation.classList.add("v-ripple__animation--visible");
transform(
animation,
`translate(${x}, ${y}) scale3d(${scale},${scale},${scale})`
);
animation.dataset.activated = String(performance.now());
setTimeout(() => {
animation.classList.remove("v-ripple__animation--enter");
animation.classList.add("v-ripple__animation--in");
transform(animation, `translate(${centerX}, ${centerY}) scale3d(1,1,1)`);
}, 0);
},
hide(el: HTMLElement | null) {
if (!el?._ripple?.enabled) return;
const ripples = el.getElementsByClassName("v-ripple__animation");
if (ripples.length === 0) return;
const animation = ripples[ripples.length - 1] as HTMLElement;
if (animation.dataset.isHiding) return;
else animation.dataset.isHiding = "true";
const diff = performance.now() - Number(animation.dataset.activated);
const delay = Math.max(250 - diff, 0);
setTimeout(() => {
animation.classList.remove("v-ripple__animation--in");
animation.classList.add("v-ripple__animation--out");
setTimeout(() => {
const ripples = el.getElementsByClassName("v-ripple__animation");
if (ripples.length === 1 && el.dataset.previousPosition) {
el.style.position = el.dataset.previousPosition;
delete el.dataset.previousPosition;
}
if (animation.parentNode?.parentNode === el)
el.removeChild(animation.parentNode);
}, 300);
}, delay);
}
};
function isRippleEnabled(value: any): value is true {
return typeof value === "undefined" || !!value;
}
function rippleShow(e: PointerEvent) {
const value: RippleOptions = {};
const element = e.currentTarget as HTMLElement | undefined;
if (!element?._ripple || element._ripple.touched) return;
value.center = element._ripple.centered;
if (element._ripple.class) {
value.class = element._ripple.class;
}
ripples.show(e, element, value);
}
function rippleHide(e: Event) {
const element = e.currentTarget as HTMLElement | null;
if (!element?._ripple) return;
window.setTimeout(() => {
if (element._ripple) {
element._ripple.touched = false;
}
});
ripples.hide(element);
}
function updateRipple(
el: HTMLElement,
binding: RippleDirectiveBinding,
wasEnabled: boolean
) {
const { value, modifiers } = binding;
const enabled = isRippleEnabled(value);
if (!enabled) {
ripples.hide(el);
}
el._ripple = el._ripple ?? {};
el._ripple.enabled = enabled;
el._ripple.centered = modifiers.center;
el._ripple.circle = modifiers.circle;
if (isObject(value) && value.class) {
el._ripple.class = value.class;
}
if (enabled && !wasEnabled) {
el.addEventListener("pointerdown", rippleShow);
el.addEventListener("pointerup", rippleHide);
} else if (!enabled && wasEnabled) {
removeListeners(el);
}
}
function removeListeners(el: HTMLElement) {
el.removeEventListener("pointerdown", rippleShow);
el.removeEventListener("pointerup", rippleHide);
}
function mounted(el: HTMLElement, binding: RippleDirectiveBinding) {
updateRipple(el, binding, false);
}
function unmounted(el: HTMLElement) {
delete el._ripple;
removeListeners(el);
}
function updated(el: HTMLElement, binding: RippleDirectiveBinding) {
if (binding.value === binding.oldValue) {
return;
}
const wasEnabled = isRippleEnabled(binding.oldValue);
updateRipple(el, binding, wasEnabled);
}
/**
* @description v-ripple
* @use
* 1. v-ripple ripple
* 2. v-ripple="{ class: 'text-red' }" ripple tailwindcss color
* 3. v-ripple.center
*/
export const Ripple: Directive = {
mounted,
unmounted,
updated
};

112
src/layout/components/appMain.vue

@ -1,5 +1,7 @@
<script setup lang="ts">
import Footer from "./footer/index.vue";
import { useGlobal } from "@pureadmin/utils";
import KeepAliveFrame from "./keepAliveFrame/index.vue";
import backTop from "@/assets/svg/back_top.svg?component";
import { h, computed, Transition, defineComponent } from "vue";
import { usePermissionStoreHook } from "@/store/modules/permission";
@ -10,7 +12,7 @@ const props = defineProps({
const { $storage, $config } = useGlobal<GlobalPropertiesApi>();
const keepAlive = computed(() => {
const isKeepAlive = computed(() => {
return $config?.KeepAlive;
});
@ -24,6 +26,10 @@ const hideTabs = computed(() => {
return $storage?.configure.hideTabs;
});
const hideFooter = computed(() => {
return $storage?.configure.hideFooter;
});
const layout = computed(() => {
return $storage?.layout.layout === "vertical";
});
@ -32,30 +38,40 @@ const getSectionStyle = computed(() => {
return [
hideTabs.value && layout ? "padding-top: 48px;" : "",
!hideTabs.value && layout ? "padding-top: 85px;" : "",
hideTabs.value && !layout.value ? "padding-top: 48px" : "",
hideTabs.value && !layout.value ? "padding-top: 48px;" : "",
!hideTabs.value && !layout.value ? "padding-top: 85px;" : "",
props.fixedHeader ? "" : "padding-top: 0;"
props.fixedHeader
? ""
: `padding-top: 0;${
hideTabs.value
? "min-height: calc(100vh - 48px);"
: "min-height: calc(100vh - 86px);"
}`
];
});
const transitionMain = defineComponent({
props: {
route: {
type: undefined,
required: true
}
},
render() {
const transitionName =
transitions.value(this.route)?.name || "fade-transform";
const enterTransition = transitions.value(this.route)?.enterTransition;
const leaveTransition = transitions.value(this.route)?.leaveTransition;
return h(
Transition,
{
name:
transitions.value(this.route) &&
this.route.meta.transition.enterTransition
? "pure-classes-transition"
: (transitions.value(this.route) &&
this.route.meta.transition.name) ||
"fade-transform",
enterActiveClass:
transitions.value(this.route) &&
`animate__animated ${this.route.meta.transition.enterTransition}`,
leaveActiveClass:
transitions.value(this.route) &&
`animate__animated ${this.route.meta.transition.leaveTransition}`,
name: enterTransition ? "pure-classes-transition" : transitionName,
enterActiveClass: enterTransition
? `animate__animated ${enterTransition}`
: undefined,
leaveActiveClass: leaveTransition
? `animate__animated ${leaveTransition}`
: undefined,
mode: "out-in",
appear: true
},
@ -63,12 +79,6 @@ const transitionMain = defineComponent({
default: () => [this.$slots.default()]
}
);
},
props: {
route: {
type: undefined,
required: true
}
}
});
</script>
@ -80,51 +90,80 @@ const transitionMain = defineComponent({
>
<router-view>
<template #default="{ Component, route }">
<el-scrollbar v-if="props.fixedHeader">
<el-backtop title="回到顶部" target=".app-main .el-scrollbar__wrap">
<KeepAliveFrame :currComp="Component" :currRoute="route">
<template #default="{ Comp, fullPath, frameInfo }">
<el-scrollbar
v-if="props.fixedHeader"
:wrap-style="{
display: 'flex',
'flex-wrap': 'wrap'
}"
:view-style="{
display: 'flex',
flex: 'auto',
overflow: 'auto',
'flex-direction': 'column'
}"
>
<el-backtop
title="回到顶部"
target=".app-main .el-scrollbar__wrap"
>
<backTop />
</el-backtop>
<div class="grow">
<transitionMain :route="route">
<keep-alive
v-if="keepAlive"
v-if="isKeepAlive"
:include="usePermissionStoreHook().cachePageList"
>
<component
:is="Component"
:key="route.fullPath"
:is="Comp"
:key="fullPath"
:frameInfo="frameInfo"
class="main-content"
/>
</keep-alive>
<component
:is="Comp"
v-else
:is="Component"
:key="route.fullPath"
:key="fullPath"
:frameInfo="frameInfo"
class="main-content"
/>
</transitionMain>
</div>
<Footer v-if="!hideFooter" />
</el-scrollbar>
<div v-else>
<div v-else class="grow">
<transitionMain :route="route">
<keep-alive
v-if="keepAlive"
v-if="isKeepAlive"
:include="usePermissionStoreHook().cachePageList"
>
<component
:is="Component"
:key="route.fullPath"
:is="Comp"
:key="fullPath"
:frameInfo="frameInfo"
class="main-content"
/>
</keep-alive>
<component
:is="Comp"
v-else
:is="Component"
:key="route.fullPath"
:key="fullPath"
:frameInfo="frameInfo"
class="main-content"
/>
</transitionMain>
</div>
</template>
</KeepAliveFrame>
</template>
</router-view>
<!-- 页脚 -->
<Footer v-if="!hideFooter && !props.fixedHeader" />
</section>
</template>
@ -138,8 +177,9 @@ const transitionMain = defineComponent({
.app-main-nofixed-header {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
min-height: 100vh;
}
.main-content {

31
src/layout/components/footer/index.vue

@ -0,0 +1,31 @@
<script lang="ts" setup>
import { getConfig } from "@/config";
const TITLE = getConfig("Title");
</script>
<template>
<footer
class="layout-footer text-[rgba(0,0,0,0.6)] dark:text-[rgba(220,220,242,0.8)]"
>
Copyright © 2020-present
<a
class="hover:text-primary"
href="https://github.com/pure-admin"
target="_blank"
>
&nbsp;{{ TITLE }}
</a>
</footer>
</template>
<style lang="scss" scoped>
.layout-footer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0 0 8px;
font-size: 14px;
}
</style>

80
src/layout/components/keepAliveFrame/index.vue

@ -0,0 +1,80 @@
<script setup lang="ts">
import { getConfig } from "@/config";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { type Component, shallowRef, watch, computed } from "vue";
import { type RouteRecordRaw, RouteLocationNormalizedLoaded } from "vue-router";
import { useMultiFrame } from "@/layout/components/keepAliveFrame/useMultiFrame";
const props = defineProps<{
currRoute: RouteLocationNormalizedLoaded;
currComp: Component;
}>();
const compList = shallowRef([]);
const { setMap, getMap, MAP, delMap } = useMultiFrame();
const keep = computed(() => {
return (
getConfig().KeepAlive &&
props.currRoute.meta?.keepAlive &&
!!props.currRoute.meta?.frameSrc
);
});
// frameView
const normalComp = computed(() => !keep.value && props.currComp);
watch(useMultiTagsStoreHook().multiTags, (tags: any) => {
if (!Array.isArray(tags) || !keep.value) {
return;
}
const iframeTags = tags.filter(i => i.meta?.frameSrc);
// tagsMAPMAPtags
if (iframeTags.length < MAP.size) {
for (const i of MAP.keys()) {
if (!tags.some(s => s.path === i)) {
delMap(i);
compList.value = getMap();
}
}
}
});
watch(
() => props.currRoute.fullPath,
path => {
const multiTags = useMultiTagsStoreHook().multiTags as RouteRecordRaw[];
const iframeTags = multiTags.filter(i => i.meta?.frameSrc);
if (keep.value) {
if (iframeTags.length !== MAP.size) {
const sameKey = [...MAP.keys()].find(i => path === i);
if (!sameKey) {
//
setMap(path, props.currComp);
}
}
}
if (MAP.size > 0) {
compList.value = getMap();
}
},
{
immediate: true
}
);
</script>
<template>
<template v-for="[fullPath, Comp] in compList" :key="fullPath">
<div v-show="fullPath === props.currRoute.fullPath" class="w-full h-full">
<slot
:fullPath="fullPath"
:Comp="Comp"
:frameInfo="{ frameSrc: currRoute.meta?.frameSrc, fullPath }"
/>
</div>
</template>
<div v-show="!keep" class="w-full h-full">
<slot :Comp="normalComp" :fullPath="props.currRoute.fullPath" frameInfo />
</div>
</template>

25
src/layout/components/keepAliveFrame/useMultiFrame.ts

@ -0,0 +1,25 @@
const MAP = new Map();
export const useMultiFrame = () => {
function setMap(path, Comp) {
MAP.set(path, Comp);
}
function getMap(path?) {
if (path) {
return MAP.get(path);
}
return [...MAP.entries()];
}
function delMap(path) {
MAP.delete(path);
}
return {
setMap,
getMap,
delMap,
MAP
};
};

10
src/layout/components/navbar.vue

@ -29,9 +29,7 @@ const { t, locale, translationCh, translationEn } = useTranslationLang();
</script>
<template>
<div
class="navbar bg-[#fff] shadow-sm shadow-[rgba(0, 21, 41, 0.08)] dark:shadow-[#0d0d0d]"
>
<div class="navbar bg-[#fff] shadow-sm shadow-[rgba(0,21,41,0.08)]">
<topCollapse
v-if="device === 'mobile'"
class="hamburger-container"
@ -48,7 +46,7 @@ const { t, locale, translationCh, translationEn } = useTranslationLang();
<div v-if="layout === 'vertical'" class="vertical-header-right">
<!-- 菜单搜索 -->
<Search />
<Search id="header-search" />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 国际化 -->
@ -64,8 +62,8 @@ const { t, locale, translationCh, translationEn } = useTranslationLang();
@click="translationCh"
>
<IconifyIconOffline
class="check-zh"
v-show="locale === 'zh'"
class="check-zh"
:icon="Check"
/>
简体中文
@ -75,7 +73,7 @@ const { t, locale, translationCh, translationEn } = useTranslationLang();
:class="['dark:!text-white', getDropdownItemClass(locale, 'en')]"
@click="translationEn"
>
<span class="check-en" v-show="locale === 'en'">
<span v-show="locale === 'en'" class="check-en">
<IconifyIconOffline :icon="Check" />
</span>
English

4
src/layout/components/notice/data.ts

@ -4,7 +4,7 @@ export interface ListItem {
datetime: string;
type: string;
description: string;
status?: "" | "success" | "warning" | "info" | "danger";
status?: "primary" | "success" | "warning" | "info" | "danger";
extra?: string;
}
@ -103,7 +103,7 @@ export const noticesData: TabItem[] = [
},
{
key: "3",
name: "办",
name: "办",
list: [
{
avatar: "",

2
src/layout/components/notice/index.vue

@ -23,8 +23,8 @@ notices.value.map(v => (noticesNum.value += v.list.length));
<template #dropdown>
<el-dropdown-menu>
<el-tabs
:stretch="true"
v-model="activeKey"
:stretch="true"
class="dropdown-tabs"
:style="{ width: notices.length === 0 ? '200px' : '330px' }"
>

4
src/layout/components/notice/noticeList.vue

@ -15,9 +15,9 @@ const props = defineProps({
<div v-if="props.list.length">
<NoticeItem
v-for="(item, index) in props.list"
:noticeItem="item"
:key="index"
:noticeItem="item"
/>
</div>
<el-empty v-else description="暂无数据" />
<el-empty v-else description="暂无消息" />
</template>

105
src/layout/components/panel/index.vue

@ -2,6 +2,7 @@
import { emitter } from "@/utils/mitt";
import { onClickOutside } from "@vueuse/core";
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import Close from "@iconify-icons/ep/close";
const target = ref(null);
@ -9,10 +10,12 @@ const show = ref<Boolean>(false);
const iconClass = computed(() => {
return [
"mr-[20px]",
"w-[22px]",
"h-[22px]",
"flex",
"justify-center",
"items-center",
"outline-none",
"width-[20px]",
"height-[20px]",
"rounded-[4px]",
"cursor-pointer",
"transition-colors",
@ -22,6 +25,8 @@ const iconClass = computed(() => {
];
});
const { onReset } = useDataThemeChange();
onClickOutside(target, (event: any) => {
if (event.clientX > target.value.offsetLeft) return;
show.value = false;
@ -40,40 +45,60 @@ onBeforeUnmount(() => {
</script>
<template>
<div :class="{ show: show }" class="right-panel-container">
<div :class="{ show }">
<div class="right-panel-background" />
<div ref="target" class="right-panel bg-bg_color">
<div class="right-panel-items">
<div class="project-configuration">
<div
class="project-configuration border-b-[1px] border-solid border-[var(--pure-border-color)]"
>
<h4 class="dark:text-white">项目配置</h4>
<span title="关闭配置" :class="iconClass">
<span
v-tippy="{
content: '关闭配置',
placement: 'bottom-start',
zIndex: 41000
}"
:class="iconClass"
>
<IconifyIconOffline
class="dark:text-white"
width="20px"
height="20px"
width="18px"
height="18px"
:icon="Close"
@click="show = !show"
/>
</span>
</div>
<div
class="border-b-[1px] border-solid border-[#dcdfe6] dark:border-[#303030]"
/>
<el-scrollbar>
<slot />
</el-scrollbar>
<div
class="flex justify-end p-3 border-t-[1px] border-solid border-[var(--pure-border-color)]"
>
<el-button
v-tippy="{
content: '清空缓存并返回登录页',
placement: 'left-start',
zIndex: 41000
}"
type="danger"
text
bg
@click="onReset"
>
清空缓存
</el-button>
</div>
</div>
</div>
</template>
<style>
.showright-panel {
position: relative;
width: calc(100% - 15px);
overflow: hidden;
<style lang="scss" scoped>
:deep(.el-scrollbar) {
height: calc(100vh - 110px);
}
</style>
<style lang="scss" scoped>
.right-panel-background {
position: fixed;
top: 0;
@ -90,8 +115,7 @@ onBeforeUnmount(() => {
right: 0;
z-index: 40000;
width: 100%;
max-width: 315px;
height: 100vh;
max-width: 280px;
box-shadow: 0 0 15px 0 rgb(0 0 0 / 5%);
transition: all 0.25s cubic-bezier(0.7, 0.3, 0.1, 1);
transform: translate(100%);
@ -112,47 +136,10 @@ onBeforeUnmount(() => {
}
}
.handle-button {
position: absolute;
top: 45%;
left: -48px;
z-index: 0;
width: 48px;
height: 48px;
font-size: 24px;
line-height: 48px;
color: #fff;
text-align: center;
pointer-events: auto;
cursor: pointer;
background: rgb(24 144 255);
border-radius: 6px 0 0 6px !important;
i {
font-size: 24px;
line-height: 48px;
}
}
.right-panel-items {
height: calc(100vh - 60px);
margin-top: 60px;
overflow-y: auto;
}
.project-configuration {
position: fixed;
top: 15px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 30px;
margin-left: 10px;
}
:deep(.el-divider--horizontal) {
width: 90%;
margin: 20px auto 0;
padding: 14px 20px;
}
</style>

8
src/layout/components/search/components/SearchFooter.vue

@ -1,9 +1,9 @@
<script setup lang="ts">
import ArrowUpLine from "@iconify-icons/ri/arrow-up-line";
import ArrowDownLine from "@iconify-icons/ri/arrow-down-line";
import { useNav } from "@/layout/hooks/useNav";
import mdiKeyboardEsc from "@/assets/svg/keyboard_esc.svg?component";
import enterOutlined from "@/assets/svg/enter_outlined.svg?component";
import ArrowUpLine from "@iconify-icons/ri/arrow-up-line";
import ArrowDownLine from "@iconify-icons/ri/arrow-down-line";
const props = withDefaults(defineProps<{ total: number }>(), {
total: 0
@ -50,7 +50,9 @@ const { device } = useNav();
padding: 2px;
margin-right: 3px;
font-size: 20px;
box-shadow: inset 0 -2px #cdcde6, inset 0 0 1px 1px #fff,
box-shadow:
inset 0 -2px #cdcde6,
inset 0 0 1px 1px #fff,
0 1px 2px 1px #1e235a66;
}

198
src/layout/components/search/components/SearchHistory.vue

@ -0,0 +1,198 @@
<script setup lang="ts">
import Sortable from "sortablejs";
import SearchHistoryItem from "./SearchHistoryItem.vue";
import type { optionsItem, dragItem, Props } from "../types";
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
import { useResizeObserver, isArray, delay } from "@pureadmin/utils";
import { ref, watch, nextTick, computed, getCurrentInstance } from "vue";
interface Emits {
(e: "update:value", val: string): void;
(e: "enter"): void;
(e: "collect", val: optionsItem): void;
(e: "delete", val: optionsItem): void;
(e: "drag", val: dragItem): void;
}
const historyRef = ref();
const innerHeight = ref();
/** 判断是否停止鼠标移入事件处理 */
const stopMouseEvent = ref(false);
const emit = defineEmits<Emits>();
const instance = getCurrentInstance()!;
const props = withDefaults(defineProps<Props>(), {});
const itemStyle = computed(() => {
return item => {
return {
background:
item?.path === active.value ? useEpThemeStoreHook().epThemeColor : "",
color: item.path === active.value ? "#fff" : "",
fontSize: item.path === active.value ? "16px" : "14px"
};
};
});
const titleStyle = computed(() => {
return {
color: useEpThemeStoreHook().epThemeColor,
fontWeight: 500
};
});
const active = computed({
get() {
return props.value;
},
set(val: string) {
emit("update:value", val);
}
});
watch(
() => props.value,
newValue => {
if (newValue) {
if (stopMouseEvent.value) {
delay(100).then(() => (stopMouseEvent.value = false));
}
}
}
);
const historyList = computed(() => {
return props.options.filter(item => item.type === "history");
});
const collectList = computed(() => {
return props.options.filter(item => item.type === "collect");
});
function handleCollect(item) {
emit("collect", item);
}
function handleDelete(item) {
stopMouseEvent.value = true;
emit("delete", item);
}
/** 鼠标移入 */
async function handleMouse(item) {
if (!stopMouseEvent.value) active.value = item.path;
}
function handleTo() {
emit("enter");
}
function resizeResult() {
// el-scrollbar max-height="calc(90vh - 140px)"
innerHeight.value = window.innerHeight - window.innerHeight / 10 - 140;
}
useResizeObserver(historyRef, resizeResult);
function handleScroll(index: number) {
const curInstance = instance?.proxy?.$refs[`historyItemRef${index}`];
if (!curInstance) return 0;
const curRef = isArray(curInstance)
? (curInstance[0] as ElRef)
: (curInstance as ElRef);
const scrollTop = curRef.offsetTop + 128; // 128 history-item56px+56px=112pxmargin8px+8px=16px
return scrollTop > innerHeight.value ? scrollTop - innerHeight.value : 0;
}
const handleChangeIndex = (evt): void => {
emit("drag", { oldIndex: evt.oldIndex, newIndex: evt.newIndex });
};
let sortableInstance = null;
watch(
collectList,
val => {
if (val.length > 1) {
nextTick(() => {
const wrapper: HTMLElement =
document.querySelector(".collect-container");
if (!wrapper || sortableInstance) return;
sortableInstance = Sortable.create(wrapper, {
animation: 160,
onStart: event => {
event.item.style.cursor = "move";
},
onEnd: event => {
event.item.style.cursor = "pointer";
},
onUpdate: handleChangeIndex
});
resizeResult();
});
}
},
{ deep: true, immediate: true }
);
defineExpose({ handleScroll });
</script>
<template>
<div ref="historyRef" class="history">
<template v-if="historyList.length">
<div :style="titleStyle">搜索历史</div>
<div
v-for="(item, index) in historyList"
:key="item.path"
:ref="'historyItemRef' + index"
class="history-item dark:bg-[#1d1d1d]"
:style="itemStyle(item)"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<SearchHistoryItem
:item="item"
@delete-item="handleDelete"
@collect-item="handleCollect"
/>
</div>
</template>
<template v-if="collectList.length">
<div :style="titleStyle">
收藏{{ collectList.length > 1 ? "(可拖拽排序)" : "" }}
</div>
<div class="collect-container">
<div
v-for="(item, index) in collectList"
:key="item.path"
:ref="'historyItemRef' + (index + historyList.length)"
class="history-item dark:bg-[#1d1d1d]"
:style="itemStyle(item)"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<SearchHistoryItem :item="item" @delete-item="handleDelete" />
</div>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
.history {
padding-bottom: 12px;
&-item {
display: flex;
align-items: center;
height: 56px;
padding: 14px;
margin: 8px auto 10px;
cursor: pointer;
border: 0.1px solid #ccc;
border-radius: 4px;
transition: font-size 0.16s;
}
}
</style>

53
src/layout/components/search/components/SearchHistoryItem.vue

@ -0,0 +1,53 @@
<script setup lang="ts">
import type { optionsItem } from "../types";
import { transformI18n } from "@/plugins/i18n";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import Star from "@iconify-icons/ep/star";
import Close from "@iconify-icons/ep/close";
interface Props {
item: optionsItem;
}
interface Emits {
(e: "collectItem", val: optionsItem): void;
(e: "deleteItem", val: optionsItem): void;
}
const emit = defineEmits<Emits>();
withDefaults(defineProps<Props>(), {});
function handleCollect(item) {
emit("collectItem", item);
}
function handleDelete(item) {
emit("deleteItem", item);
}
</script>
<template>
<component :is="useRenderIcon(item.meta?.icon)" />
<span class="history-item-title">
{{ transformI18n(item.meta?.title) }}
</span>
<IconifyIconOffline
v-show="item.type === 'history'"
:icon="Star"
class="w-[18px] h-[18px] mr-2 hover:text-[#d7d5d4]"
@click.stop="handleCollect(item)"
/>
<IconifyIconOffline
:icon="Close"
class="w-[18px] h-[18px] hover:text-[#d7d5d4] cursor-pointer"
@click.stop="handleDelete(item)"
/>
</template>
<style lang="scss" scoped>
.history-item-title {
display: flex;
flex: 1;
margin-left: 5px;
}
</style>

226
src/layout/components/search/components/SearchModal.vue

@ -1,15 +1,18 @@
<script setup lang="ts">
import { match } from "pinyin-pro";
import { useI18n } from "vue-i18n";
import { getConfig } from "@/config";
import { useRouter } from "vue-router";
import SearchResult from "./SearchResult.vue";
import SearchFooter from "./SearchFooter.vue";
import { useNav } from "@/layout/hooks/useNav";
import { transformI18n } from "@/plugins/i18n";
import { ref, computed, shallowRef } from "vue";
import { cloneDeep, isAllEmpty } from "@pureadmin/utils";
import SearchHistory from "./SearchHistory.vue";
import type { optionsItem, dragItem } from "../types";
import { ref, computed, shallowRef, watch } from "vue";
import { useDebounceFn, onKeyStroke } from "@vueuse/core";
import { usePermissionStoreHook } from "@/store/modules/permission";
import { cloneDeep, isAllEmpty, storageLocal } from "@pureadmin/utils";
import Search from "@iconify-icons/ri/search-line";
interface Props {
@ -24,16 +27,26 @@ interface Emits {
const { device } = useNav();
const emit = defineEmits<Emits>();
const props = withDefaults(defineProps<Props>(), {});
const router = useRouter();
const { locale } = useI18n();
const HISTORY_TYPE = "history";
const COLLECT_TYPE = "collect";
const LOCALEHISTORYKEY = "menu-search-history";
const LOCALECOLLECTKEY = "menu-search-collect";
const keyword = ref("");
const scrollbarRef = ref();
const resultRef = ref();
const historyRef = ref();
const scrollbarRef = ref();
const activePath = ref("");
const inputRef = ref<HTMLInputElement | null>(null);
const historyPath = ref("");
const resultOptions = shallowRef([]);
const historyOptions = shallowRef([]);
const handleSearch = useDebounceFn(search, 300);
const historyNum = getConfig().MenuSearchHistory;
const inputRef = ref<HTMLInputElement | null>(null);
/** 菜单树形结构 */
const menusData = computed(() => {
@ -49,6 +62,36 @@ const show = computed({
}
});
watch(
() => props.value,
newValue => {
if (newValue) getHistory();
}
);
const showSearchResult = computed(() => {
return keyword.value && resultOptions.value.length > 0;
});
const showSearchHistory = computed(() => {
return !keyword.value && historyOptions.value.length > 0;
});
const showEmpty = computed(() => {
return (
(!keyword.value && historyOptions.value.length === 0) ||
(keyword.value && resultOptions.value.length === 0)
);
});
function getStorageItem(key) {
return storageLocal().getItem<optionsItem[]>(key) || [];
}
function setStorageItem(key, value) {
storageLocal().setItem(key, value);
}
/** 将菜单树形结构扁平化为一维数组,用于菜单查询 */
function flatTree(arr) {
const res = [];
@ -79,11 +122,8 @@ function search() {
))
: false
);
if (resultOptions.value?.length > 0) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = "";
}
activePath.value =
resultOptions.value?.length > 0 ? resultOptions.value[0].path : "";
}
function handleClose() {
@ -91,54 +131,143 @@ function handleClose() {
/** 延时处理防止用户看到某些操作 */
setTimeout(() => {
resultOptions.value = [];
historyPath.value = "";
keyword.value = "";
}, 200);
}
function scrollTo(index) {
const scrollTop = resultRef.value.handleScroll(index);
const ref = resultOptions.value.length ? resultRef.value : historyRef.value;
const scrollTop = ref.handleScroll(index);
scrollbarRef.value.setScrollTop(scrollTop);
}
/** key up */
function handleUp() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(
item => item.path === activePath.value
);
if (index === 0) {
activePath.value = resultOptions.value[length - 1].path;
scrollTo(resultOptions.value.length - 1);
/** 获取当前选项和路径 */
function getCurrentOptionsAndPath() {
const isResultOptions = resultOptions.value.length > 0;
const options = isResultOptions ? resultOptions.value : historyOptions.value;
const currentPath = isResultOptions ? activePath.value : historyPath.value;
return { options, currentPath, isResultOptions };
}
/** 更新路径并滚动到指定项 */
function updatePathAndScroll(newIndex, isResultOptions) {
if (isResultOptions) {
activePath.value = resultOptions.value[newIndex].path;
} else {
activePath.value = resultOptions.value[index - 1].path;
scrollTo(index - 1);
historyPath.value = historyOptions.value[newIndex].path;
}
scrollTo(newIndex);
}
/** key up */
function handleUp() {
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
if (options.length === 0) return;
const index = options.findIndex(item => item.path === currentPath);
const prevIndex = (index - 1 + options.length) % options.length;
updatePathAndScroll(prevIndex, isResultOptions);
}
/** key down */
function handleDown() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(
item => item.path === activePath.value
);
if (index + 1 === length) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = resultOptions.value[index + 1].path;
}
scrollTo(index + 1);
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
if (options.length === 0) return;
const index = options.findIndex(item => item.path === currentPath);
const nextIndex = (index + 1) % options.length;
updatePathAndScroll(nextIndex, isResultOptions);
}
/** key enter */
function handleEnter() {
const { length } = resultOptions.value;
if (length === 0 || activePath.value === "") return;
router.push(activePath.value);
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
if (options.length === 0 || currentPath === "") return;
const index = options.findIndex(item => item.path === currentPath);
if (index === -1) return;
if (isResultOptions) {
saveHistory();
} else {
updateHistory();
}
router.push(options[index].path);
handleClose();
}
/** 删除历史记录 */
function handleDelete(item) {
const key = item.type === HISTORY_TYPE ? LOCALEHISTORYKEY : LOCALECOLLECTKEY;
let list = getStorageItem(key);
list = list.filter(listItem => listItem.path !== item.path);
setStorageItem(key, list);
getHistory();
}
/** 收藏历史记录 */
function handleCollect(item) {
let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
let searchCollectList = getStorageItem(LOCALECOLLECTKEY);
searchHistoryList = searchHistoryList.filter(
historyItem => historyItem.path !== item.path
);
setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
if (!searchCollectList.some(collectItem => collectItem.path === item.path)) {
searchCollectList.unshift({ ...item, type: COLLECT_TYPE });
setStorageItem(LOCALECOLLECTKEY, searchCollectList);
}
getHistory();
}
/** 存储搜索记录 */
function saveHistory() {
const { path, meta } = resultOptions.value.find(
item => item.path === activePath.value
);
const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
const isCollected = searchCollectList.some(item => item.path === path);
const existingIndex = searchHistoryList.findIndex(item => item.path === path);
if (!isCollected) {
if (existingIndex !== -1) searchHistoryList.splice(existingIndex, 1);
if (searchHistoryList.length >= historyNum) searchHistoryList.pop();
searchHistoryList.unshift({ path, meta, type: HISTORY_TYPE });
storageLocal().setItem(LOCALEHISTORYKEY, searchHistoryList);
}
}
/** 更新存储的搜索记录 */
function updateHistory() {
let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
const historyIndex = searchHistoryList.findIndex(
item => item.path === historyPath.value
);
if (historyIndex !== -1) {
const [historyItem] = searchHistoryList.splice(historyIndex, 1);
searchHistoryList.unshift(historyItem);
setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
}
}
/** 获取本地历史记录 */
function getHistory() {
const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
historyOptions.value = [...searchHistoryList, ...searchCollectList];
historyPath.value = historyOptions.value[0]?.path;
}
/** 拖拽改变收藏顺序 */
function handleDrag(item: dragItem) {
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
const [reorderedItem] = searchCollectList.splice(item.oldIndex, 1);
searchCollectList.splice(item.newIndex, 0, reorderedItem);
storageLocal().setItem(LOCALECOLLECTKEY, searchCollectList);
historyOptions.value = [
...getStorageItem(LOCALEHISTORYKEY),
...getStorageItem(LOCALECOLLECTKEY)
];
historyPath.value = reorderedItem.path;
}
onKeyStroke("Enter", handleEnter);
onKeyStroke("ArrowUp", handleUp);
onKeyStroke("ArrowDown", handleDown);
@ -146,9 +275,9 @@ onKeyStroke("ArrowDown", handleDown);
<template>
<el-dialog
v-model="show"
top="5vh"
class="pure-search-dialog"
v-model="show"
:show-close="false"
:width="device === 'mobile' ? '80vw' : '40vw'"
:before-close="handleClose"
@ -161,10 +290,10 @@ onKeyStroke("ArrowDown", handleDown);
>
<el-input
ref="inputRef"
size="large"
v-model="keyword"
size="large"
clearable
placeholder="搜索菜单"
placeholder="搜索菜单(中文模式下支持拼音搜索)"
@input="handleSearch"
>
<template #prefix>
@ -174,14 +303,21 @@ onKeyStroke("ArrowDown", handleDown);
/>
</template>
</el-input>
<div class="search-result-container">
<div class="search-content">
<el-scrollbar ref="scrollbarRef" max-height="calc(90vh - 140px)">
<el-empty
v-if="resultOptions.length === 0"
description="暂无搜索结果"
<el-empty v-if="showEmpty" description="暂无搜索结果" />
<SearchHistory
v-if="showSearchHistory"
ref="historyRef"
v-model:value="historyPath"
:options="historyOptions"
@click="handleEnter"
@delete="handleDelete"
@collect="handleCollect"
@drag="handleDrag"
/>
<SearchResult
v-else
v-if="showSearchResult"
ref="resultRef"
v-model:value="activePath"
:options="resultOptions"
@ -196,7 +332,7 @@ onKeyStroke("ArrowDown", handleDown);
</template>
<style lang="scss" scoped>
.search-result-container {
.search-content {
margin-top: 12px;
}
</style>

27
src/layout/components/search/components/SearchResult.vue

@ -1,24 +1,11 @@
<script setup lang="ts">
import type { Props } from "../types";
import { transformI18n } from "@/plugins/i18n";
import { useResizeObserver } from "@vueuse/core";
import { useResizeObserver } from "@pureadmin/utils";
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { ref, computed, getCurrentInstance, onMounted } from "vue";
import enterOutlined from "@/assets/svg/enter_outlined.svg?component";
import Bookmark2Line from "@iconify-icons/ri/bookmark-2-line";
interface optionsItem {
path: string;
meta?: {
icon?: string;
title?: string;
};
}
interface Props {
value: string;
options: Array<optionsItem>;
}
interface Emits {
(e: "update:value", val: string): void;
@ -27,9 +14,9 @@ interface Emits {
const resultRef = ref();
const innerHeight = ref();
const props = withDefaults(defineProps<Props>(), {});
const emit = defineEmits<Emits>();
const instance = getCurrentInstance()!;
const props = withDefaults(defineProps<Props>(), {});
const itemStyle = computed(() => {
return item => {
@ -65,9 +52,7 @@ function resizeResult() {
innerHeight.value = window.innerHeight - window.innerHeight / 10 - 140;
}
useResizeObserver(resultRef, () => {
resizeResult();
});
useResizeObserver(resultRef, resizeResult);
function handleScroll(index: number) {
const curInstance = instance?.proxy?.$refs[`resultItemRef${index}`];
@ -95,7 +80,7 @@ defineExpose({ handleScroll });
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<component :is="useRenderIcon(item.meta?.icon ?? Bookmark2Line)" />
<component :is="useRenderIcon(item.meta?.icon)" />
<span class="result-item-title">
{{ transformI18n(item.meta?.title) }}
</span>
@ -117,7 +102,7 @@ defineExpose({ handleScroll });
cursor: pointer;
border: 0.1px solid #ccc;
border-radius: 4px;
transition: all 0.3s;
transition: font-size 0.16s;
&-title {
display: flex;

5
src/layout/components/search/index.vue

@ -1,7 +1,6 @@
<script setup lang="ts">
import { SearchModal } from "./components";
import { useBoolean } from "../../hooks/useBoolean";
import Search from "@iconify-icons/ep/search";
const { bool: show, toggle } = useBoolean();
function handleSearch() {
@ -10,11 +9,13 @@ function handleSearch() {
</script>
<template>
<div>
<div
class="search-container w-[40px] h-[48px] flex-c cursor-pointer navbar-bg-hover"
@click="handleSearch"
>
<IconifyIconOffline :icon="Search" />
<IconifyIconOffline icon="ri:search-line" />
</div>
<SearchModal v-model:value="show" />
</div>
</template>

20
src/layout/components/search/types.ts

@ -0,0 +1,20 @@
interface optionsItem {
path: string;
type: "history" | "collect";
meta: {
icon?: string;
title?: string;
};
}
interface dragItem {
oldIndex: number;
newIndex: number;
}
interface Props {
value: string;
options: Array<optionsItem>;
}
export type { optionsItem, dragItem, Props };

387
src/layout/components/setting/index.vue

@ -6,36 +6,26 @@ import {
reactive,
computed,
nextTick,
onUnmounted,
onBeforeMount
} from "vue";
import {
useDark,
debounce,
useGlobal,
storageLocal,
storageSession
} from "@pureadmin/utils";
import { getConfig } from "@/config";
import { useRouter } from "vue-router";
import panel from "../panel/index.vue";
import { emitter } from "@/utils/mitt";
import { resetRouter } from "@/router";
import { removeToken } from "@/utils/auth";
import { routerArrays } from "@/layout/types";
import { useNav } from "@/layout/hooks/useNav";
import { useAppStoreHook } from "@/store/modules/app";
import { useDark, useGlobal, debounce } from "@pureadmin/utils";
import { toggleTheme } from "@pureadmin/theme/dist/browser-utils";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import Segmented, { type OptionsType } from "@/components/ReSegmented";
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
import Check from "@iconify-icons/ep/check";
import dayIcon from "@/assets/svg/day.svg?component";
import darkIcon from "@/assets/svg/dark.svg?component";
import Check from "@iconify-icons/ep/check";
import Logout from "@iconify-icons/ri/logout-circle-r-line";
import systemIcon from "@/assets/svg/system.svg?component";
const router = useRouter();
const { device } = useNav();
const { isDark } = useDark();
const { device, tooltipEffect } = useNav();
const { $storage } = useGlobal<GlobalPropertiesApi>();
const mixRef = ref();
@ -44,10 +34,11 @@ const horizontalRef = ref();
const {
dataTheme,
overallStyle,
layoutTheme,
themeColors,
toggleClass,
dataThemeChange,
setEpThemeColor,
setLayoutThemeColor
} = useDataThemeChange();
@ -72,6 +63,7 @@ const settings = reactive({
tabsVal: $storage.configure.hideTabs,
showLogo: $storage.configure.showLogo,
showModel: $storage.configure.showModel,
hideFooter: $storage.configure.hideFooter,
multiTagsCache: $storage.configure.multiTagsCache
});
@ -81,7 +73,7 @@ const getThemeColorStyle = computed(() => {
};
});
/** 当网页为暗黑模式时不显示亮白色切换选项 */
/** 当网页整体为暗色风格时不显示亮白色主题配色切换选项 */
const showThemeColors = computed(() => {
return themeColor => {
return themeColor === "light" && isDark.value ? false : true;
@ -94,60 +86,45 @@ function storageConfigureChange<T>(key: string, val: T): void {
$storage.configure = storageConfigure;
}
function toggleClass(flag: boolean, clsName: string, target?: HTMLElement) {
const targetEl = target || document.body;
let { className } = targetEl;
className = className.replace(clsName, "").trim();
targetEl.className = flag ? `${className} ${clsName} ` : className;
}
/** 灰色模式设置 */
const greyChange = (value): void => {
toggleClass(settings.greyVal, "html-grey", document.querySelector("html"));
const htmlEl = document.querySelector("html");
toggleClass(settings.greyVal, "html-grey", htmlEl);
storageConfigureChange("grey", value);
};
/** 色弱模式设置 */
const weekChange = (value): void => {
toggleClass(
settings.weakVal,
"html-weakness",
document.querySelector("html")
);
const htmlEl = document.querySelector("html");
toggleClass(settings.weakVal, "html-weakness", htmlEl);
storageConfigureChange("weak", value);
};
/** 隐藏标签页设置 */
const tagsChange = () => {
const showVal = settings.tabsVal;
storageConfigureChange("hideTabs", showVal);
emitter.emit("tagViewsChange", showVal as unknown as string);
};
/** 隐藏页脚设置 */
const hideFooterChange = () => {
const hideFooter = settings.hideFooter;
storageConfigureChange("hideFooter", hideFooter);
};
/** 标签页持久化设置 */
const multiTagsCacheChange = () => {
const multiTagsCache = settings.multiTagsCache;
storageConfigureChange("multiTagsCache", multiTagsCache);
useMultiTagsStoreHook().multiTagsCacheChange(multiTagsCache);
};
/** 清空缓存并返回登录页 */
function onReset() {
removeToken();
storageLocal().clear();
storageSession().clear();
const { Grey, Weak, MultiTagsCache, EpThemeColor, Layout } = getConfig();
useAppStoreHook().setLayout(Layout);
setEpThemeColor(EpThemeColor);
useMultiTagsStoreHook().multiTagsCacheChange(MultiTagsCache);
toggleClass(Grey, "html-grey", document.querySelector("html"));
toggleClass(Weak, "html-weakness", document.querySelector("html"));
router.push("/login");
useMultiTagsStoreHook().handleTags("equal", [...routerArrays]);
resetRouter();
}
function onChange(label) {
storageConfigureChange("showModel", label);
emitter.emit("tagViewsShowModel", label);
function onChange({ option }) {
const { value } = option;
markValue.value = value;
storageConfigureChange("showModel", value);
emitter.emit("tagViewsShowModel", value);
}
/** 侧边栏Logo */
@ -183,6 +160,45 @@ const getThemeColor = computed(() => {
};
});
const themeOptions = computed<Array<OptionsType>>(() => {
return [
{
label: "浅色",
icon: dayIcon,
theme: "light",
tip: "清新启航,点亮舒适的工作界面",
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
},
{
label: "深色",
icon: darkIcon,
theme: "dark",
tip: "月光序曲,沉醉于夜的静谧雅致",
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
},
{
label: "自动",
icon: systemIcon,
theme: "system",
tip: "同步时光,界面随晨昏自然呼应",
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
}
];
});
const markOptions: Array<OptionsType> = [
{
label: "灵动",
tip: "灵动标签,添趣生辉",
value: "smart"
},
{
label: "卡片",
tip: "卡片标签,高效浏览",
value: "card"
}
];
/** 设置导航模式 */
function setLayoutModel(layout: string) {
layoutTheme.value.layout = layout;
@ -192,7 +208,9 @@ function setLayoutModel(layout: string) {
theme: layoutTheme.value.theme,
darkMode: $storage.layout?.darkMode,
sidebarStatus: $storage.layout?.sidebarStatus,
epThemeColor: $storage.layout?.epThemeColor
epThemeColor: $storage.layout?.epThemeColor,
themeColor: $storage.layout?.themeColor,
overallStyle: $storage.layout?.overallStyle
};
useAppStoreHook().setLayout(layout);
}
@ -217,113 +235,142 @@ watch($storage, ({ layout }) => {
}
});
const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
/** 根据操作系统主题设置平台整体风格 */
function updateTheme() {
if (overallStyle.value !== "system") return;
if (mediaQueryList.matches) {
dataTheme.value = true;
} else {
dataTheme.value = false;
}
dataThemeChange(overallStyle.value);
}
function removeMatchMedia() {
mediaQueryList.removeEventListener("change", updateTheme);
}
/** 监听操作系统主题改变 */
function watchSystemThemeChange() {
updateTheme();
removeMatchMedia();
mediaQueryList.addEventListener("change", updateTheme);
}
onBeforeMount(() => {
/* 初始化项目配置 */
nextTick(() => {
watchSystemThemeChange();
settings.greyVal &&
document.querySelector("html")?.setAttribute("class", "html-grey");
document.querySelector("html")?.classList.add("html-grey");
settings.weakVal &&
document.querySelector("html")?.setAttribute("class", "html-weakness");
document.querySelector("html")?.classList.add("html-weakness");
settings.tabsVal && tagsChange();
settings.hideFooter && hideFooterChange();
});
});
onUnmounted(() => removeMatchMedia);
</script>
<template>
<panel>
<el-divider>主题</el-divider>
<el-switch
v-model="dataTheme"
inline-prompt
class="pure-datatheme"
:active-icon="dayIcon"
:inactive-icon="darkIcon"
@change="dataThemeChange"
<div class="p-6">
<p class="mb-3 font-medium text-sm dark:text-white">整体风格</p>
<Segmented
class="select-none"
:modelValue="overallStyle === 'system' ? 2 : dataTheme ? 1 : 0"
:options="themeOptions"
@change="
theme => {
theme.index === 1 && theme.index !== 2
? (dataTheme = true)
: (dataTheme = false);
overallStyle = theme.option.theme;
dataThemeChange(theme.option.theme);
theme.index === 2 && watchSystemThemeChange();
}
"
/>
<el-divider>导航栏模式</el-divider>
<ul class="pure-theme">
<el-tooltip
:effect="tooltipEffect"
class="item"
content="左侧模式"
placement="bottom"
popper-class="pure-tooltip"
<p class="mt-5 mb-3 font-medium text-sm dark:text-white">主题色</p>
<ul class="theme-color">
<li
v-for="(item, index) in themeColors"
v-show="showThemeColors(item.themeColor)"
:key="index"
:style="getThemeColorStyle(item.color)"
@click="setLayoutThemeColor(item.themeColor)"
>
<el-icon
style="margin: 0.1em 0.1em 0 0"
:size="17"
:color="getThemeColor(item.themeColor)"
>
<IconifyIconOffline :icon="Check" />
</el-icon>
</li>
</ul>
<p class="mt-5 mb-3 font-medium text-sm dark:text-white">导航模式</p>
<ul class="pure-theme">
<li
:class="layoutTheme.layout === 'vertical' ? 'is-select' : ''"
ref="verticalRef"
v-tippy="{
content: '左侧菜单,亲切熟悉',
zIndex: 41000
}"
:class="layoutTheme.layout === 'vertical' ? 'is-select' : ''"
@click="setLayoutModel('vertical')"
>
<div />
<div />
</li>
</el-tooltip>
<el-tooltip
v-if="device !== 'mobile'"
:effect="tooltipEffect"
class="item"
content="顶部模式"
placement="bottom"
popper-class="pure-tooltip"
>
<li
:class="layoutTheme.layout === 'horizontal' ? 'is-select' : ''"
v-if="device !== 'mobile'"
ref="horizontalRef"
v-tippy="{
content: '顶部菜单,简洁概览',
zIndex: 41000
}"
:class="layoutTheme.layout === 'horizontal' ? 'is-select' : ''"
@click="setLayoutModel('horizontal')"
>
<div />
<div />
</li>
</el-tooltip>
<el-tooltip
v-if="device !== 'mobile'"
:effect="tooltipEffect"
class="item"
content="混合模式"
placement="bottom"
popper-class="pure-tooltip"
>
<li
:class="layoutTheme.layout === 'mix' ? 'is-select' : ''"
v-if="device !== 'mobile'"
ref="mixRef"
v-tippy="{
content: '混合菜单,灵活多变',
zIndex: 41000
}"
:class="layoutTheme.layout === 'mix' ? 'is-select' : ''"
@click="setLayoutModel('mix')"
>
<div />
<div />
</li>
</el-tooltip>
</ul>
<el-divider>主题色</el-divider>
<ul class="theme-color">
<li
v-for="(item, index) in themeColors"
:key="index"
v-show="showThemeColors(item.themeColor)"
:style="getThemeColorStyle(item.color)"
@click="setLayoutThemeColor(item.themeColor)"
>
<el-icon
style="margin: 0.1em 0.1em 0 0"
:size="17"
:color="getThemeColor(item.themeColor)"
>
<IconifyIconOffline :icon="Check" />
</el-icon>
</li>
</ul>
<p class="mt-5 mb-3 font-medium text-base dark:text-white">页签风格</p>
<Segmented
class="select-none"
:modelValue="markValue === 'smart' ? 0 : 1"
:options="markOptions"
@change="onChange"
/>
<el-divider>界面显示</el-divider>
<p class="mt-5 mb-1 font-medium text-sm dark:text-white">界面显示</p>
<ul class="setting">
<li>
<span class="dark:text-white">灰色模式</span>
<el-switch
v-model="settings.greyVal"
inline-prompt
inactive-color="#a6a6a6"
active-text="开"
inactive-text="关"
@change="greyChange"
@ -334,7 +381,6 @@ onBeforeMount(() => {
<el-switch
v-model="settings.weakVal"
inline-prompt
inactive-color="#a6a6a6"
active-text="开"
inactive-text="关"
@change="weekChange"
@ -345,60 +391,45 @@ onBeforeMount(() => {
<el-switch
v-model="settings.tabsVal"
inline-prompt
inactive-color="#a6a6a6"
active-text="开"
inactive-text="关"
@change="tagsChange"
/>
</li>
<li>
<span class="dark:text-white">侧边栏Logo</span>
<span class="dark:text-white">隐藏页脚</span>
<el-switch
v-model="settings.hideFooter"
inline-prompt
active-text="开"
inactive-text="关"
@change="hideFooterChange"
/>
</li>
<li>
<span class="dark:text-white">Logo</span>
<el-switch
v-model="logoVal"
inline-prompt
:active-value="true"
:inactive-value="false"
inactive-color="#a6a6a6"
active-text="开"
inactive-text="关"
@change="logoChange"
/>
</li>
<li>
<span class="dark:text-white">标签页持久化</span>
<span class="dark:text-white">页签持久化</span>
<el-switch
v-model="settings.multiTagsCache"
inline-prompt
inactive-color="#a6a6a6"
active-text="开"
inactive-text="关"
@change="multiTagsCacheChange"
/>
</li>
<li>
<span class="dark:text-white">标签风格</span>
<el-radio-group v-model="markValue" size="small" @change="onChange">
<el-radio label="card">卡片</el-radio>
<el-radio label="smart">灵动</el-radio>
</el-radio-group>
</li>
</ul>
<el-divider />
<el-button
type="danger"
style="width: 90%; margin: 24px 15px"
@click="onReset"
>
<IconifyIconOffline
:icon="Logout"
width="15"
height="15"
style="margin-right: 4px"
/>
清空缓存并返回登录页
</el-button>
</div>
</panel>
</template>
@ -408,41 +439,41 @@ onBeforeMount(() => {
font-weight: 700;
}
.is-select {
border: 2px solid var(--el-color-primary);
:deep(.el-switch__core) {
--el-switch-off-color: var(--pure-switch-off-color);
min-width: 36px;
height: 18px;
}
.setting {
width: 100%;
:deep(.el-switch__core .el-switch__action) {
height: 14px;
}
.theme-color {
height: 20px;
li {
display: flex;
align-items: center;
justify-content: space-between;
margin: 25px;
float: left;
height: 20px;
margin-right: 8px;
cursor: pointer;
border-radius: 4px;
&:nth-child(1) {
border: 1px solid #ddd;
}
}
.pure-datatheme {
display: block;
width: 100%;
height: 50px;
padding-top: 25px;
text-align: center;
}
.pure-theme {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
width: 100%;
height: 50px;
margin-top: 25px;
gap: 12px;
li {
position: relative;
width: 18%;
height: 45px;
width: 46px;
height: 36px;
overflow: hidden;
cursor: pointer;
background: #f0f2f5;
@ -503,27 +534,17 @@ onBeforeMount(() => {
}
}
.theme-color {
display: flex;
justify-content: center;
width: 100%;
height: 40px;
margin-top: 20px;
.is-select {
border: 2px solid var(--el-color-primary);
}
.setting {
li {
float: left;
width: 20px;
height: 20px;
margin-top: 8px;
margin-right: 8px;
font-weight: 700;
text-align: center;
cursor: pointer;
border-radius: 2px;
&:nth-child(2) {
border: 1px solid #ddd;
}
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
font-size: 14px;
}
}
</style>

26
src/layout/components/sidebar/breadCrumb.vue

@ -1,10 +1,10 @@
<script setup lang="ts">
import { isEqual } from "@pureadmin/utils";
import { transformI18n } from "@/plugins/i18n";
import { useRoute, useRouter } from "vue-router";
import { ref, watch, onMounted, toRaw } from "vue";
import { getParentPaths, findRouteByPath } from "@/router/utils";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { useRoute, useRouter, RouteLocationMatched } from "vue-router";
const route = useRoute();
const levelList = ref([]);
@ -64,12 +64,28 @@ const getBreadcrumb = (): void => {
);
};
const handleLink = (item: RouteLocationMatched): void => {
const { redirect, path } = item;
const handleLink = item => {
const { redirect, name, path } = item;
if (redirect) {
router.push(redirect as any);
} else {
router.push(path);
if (name) {
if (item.query) {
router.push({
name,
query: item.query
});
} else if (item.params) {
router.push({
name,
params: item.params
});
} else {
router.push({ name });
}
} else {
router.push({ path });
}
}
};
@ -92,9 +108,9 @@ watch(
<el-breadcrumb class="!leading-[50px] select-none" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item
class="!inline !items-stretch"
v-for="item in levelList"
:key="item.path"
class="!inline !items-stretch"
>
<a @click.prevent="handleLink(item)">
{{ transformI18n(item.meta.title) }}

12
src/layout/components/sidebar/horizontal.vue

@ -21,6 +21,7 @@ const {
logout,
backTopMenu,
onPanel,
getLogo,
username,
userAvatar,
avatarsStyle,
@ -43,13 +44,14 @@ nextTick(() => {
class="horizontal-header"
>
<div class="horizontal-header-left" @click="backTopMenu">
<img src="/logo.svg" alt="logo" />
<img :src="getLogo()" alt="logo" />
<span>{{ title }}</span>
</div>
<el-menu
router
ref="menuRef"
router
mode="horizontal"
popper-class="pure-scrollbar"
class="horizontal-header-menu"
:default-active="defaultActive"
>
@ -62,7 +64,7 @@ nextTick(() => {
</el-menu>
<div class="horizontal-header-right">
<!-- 菜单搜索 -->
<Search />
<Search id="header-search" />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 国际化 -->
@ -77,7 +79,7 @@ nextTick(() => {
:class="['dark:!text-white', getDropdownItemClass(locale, 'zh')]"
@click="translationCh"
>
<span class="check-zh" v-show="locale === 'zh'">
<span v-show="locale === 'zh'" class="check-zh">
<IconifyIconOffline :icon="Check" />
</span>
简体中文
@ -87,7 +89,7 @@ nextTick(() => {
:class="['dark:!text-white', getDropdownItemClass(locale, 'en')]"
@click="translationEn"
>
<span class="check-en" v-show="locale === 'en'">
<span v-show="locale === 'en'" class="check-en">
<IconifyIconOffline :icon="Check" />
</span>
English

36
src/layout/components/sidebar/leftCollapse.vue

@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { computed } from "vue";
import { useGlobal } from "@pureadmin/utils";
import { useNav } from "@/layout/hooks/useNav";
import MenuFold from "@iconify-icons/ri/menu-fold-fill";
interface Props {
@ -11,7 +13,6 @@ const props = withDefaults(defineProps<Props>(), {
isActive: false
});
const visible = ref(false);
const { tooltipEffect } = useNav();
const iconClass = computed(() => {
@ -22,14 +23,14 @@ const iconClass = computed(() => {
"h-[16px]",
"inline-block",
"align-middle",
"text-primary",
"cursor-pointer",
"duration-[100ms]",
"hover:text-primary",
"dark:hover:!text-white"
"duration-[100ms]"
];
});
const { $storage } = useGlobal<GlobalPropertiesApi>();
const themeColor = computed(() => $storage.layout?.themeColor);
const emit = defineEmits<{
(e: "toggleClick"): void;
}>();
@ -40,32 +41,29 @@ const toggleClick = () => {
</script>
<template>
<div class="container">
<el-tooltip
placement="right"
:visible="visible"
:effect="tooltipEffect"
:content="props.isActive ? '点击折叠' : '点击展开'"
>
<div class="collapse-container">
<IconifyIconOffline
v-tippy="{
content: props.isActive ? '点击折叠' : '点击展开',
theme: tooltipEffect,
hideOnClick: 'toggle',
placement: 'right'
}"
:icon="MenuFold"
:class="iconClass"
:class="[iconClass, themeColor === 'light' ? '' : 'text-primary']"
:style="{ transform: props.isActive ? 'none' : 'rotateY(180deg)' }"
@click="toggleClick"
@mouseenter="visible = true"
@mouseleave="visible = false"
/>
</el-tooltip>
</div>
</template>
<style lang="scss" scoped>
.container {
.collapse-container {
position: absolute;
bottom: 0;
width: 100%;
height: 40px;
line-height: 40px;
box-shadow: 0 0 6px -2px var(--el-color-primary);
box-shadow: 0 0 6px -3px var(--el-color-primary);
}
</style>

36
src/layout/components/sidebar/linkItem.vue

@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from "vue";
import { isUrl } from "@pureadmin/utils";
import { menuType } from "@/layout/types";
defineOptions({
name: "LinkItem"
});
const props = defineProps<{
to: menuType;
}>();
const isExternalLink = computed(() => isUrl(props.to.name));
const getLinkProps = (item: menuType) => {
if (isExternalLink.value) {
return {
href: item.name,
target: "_blank",
rel: "noopener"
};
}
return {
to: item
};
};
</script>
<template>
<component
:is="isExternalLink ? 'a' : 'router-link'"
v-bind="getLinkProps(to)"
>
<slot />
</component>
</template>

7
src/layout/components/sidebar/logo.vue

@ -6,7 +6,7 @@ const props = defineProps({
collapse: Boolean
});
const { title } = useNav();
const { title, getLogo } = useNav();
</script>
<template>
@ -19,7 +19,7 @@ const { title } = useNav();
class="sidebar-logo-link"
:to="getTopMenu()?.path ?? '/'"
>
<img src="/logo.svg" alt="logo" />
<img :src="getLogo()" alt="logo" />
<span class="sidebar-title">{{ title }}</span>
</router-link>
<router-link
@ -29,7 +29,7 @@ const { title } = useNav();
class="sidebar-logo-link"
:to="getTopMenu()?.path ?? '/'"
>
<img src="/logo.svg" alt="logo" />
<img :src="getLogo()" alt="logo" />
<span class="sidebar-title">{{ title }}</span>
</router-link>
</transition>
@ -48,6 +48,7 @@ const { title } = useNav();
flex-wrap: nowrap;
align-items: center;
height: 100%;
padding-left: 10px;
img {
display: inline-block;

11
src/layout/components/sidebar/mixNav.vue

@ -61,13 +61,14 @@ watch(
<template>
<div
v-if="device !== 'mobile'"
class="horizontal-header"
v-loading="usePermissionStoreHook().wholeMenus.length === 0"
class="horizontal-header"
>
<el-menu
router
ref="menuRef"
router
mode="horizontal"
popper-class="pure-scrollbar"
class="horizontal-header-menu"
:default-active="defaultActive"
>
@ -96,7 +97,7 @@ watch(
</el-menu>
<div class="horizontal-header-right">
<!-- 菜单搜索 -->
<Search />
<Search id="header-search" />
<!-- 通知 -->
<Notice id="header-notice" />
<!-- 国际化 -->
@ -111,7 +112,7 @@ watch(
:class="['dark:!text-white', getDropdownItemClass(locale, 'zh')]"
@click="translationCh"
>
<span class="check-zh" v-show="locale === 'zh'">
<span v-show="locale === 'zh'" class="check-zh">
<IconifyIconOffline :icon="Check" />
</span>
简体中文
@ -121,7 +122,7 @@ watch(
:class="['dark:!text-white', getDropdownItemClass(locale, 'en')]"
@click="translationEn"
>
<span class="check-en" v-show="locale === 'en'">
<span v-show="locale === 'en'" class="check-en">
<IconifyIconOffline :icon="Check" />
</span>
English

209
src/layout/components/sidebar/sidebarItem.vue

@ -1,18 +1,28 @@
<script setup lang="ts">
import path from "path";
import { getConfig } from "@/config";
import LinkItem from "./linkItem.vue";
import { menuType } from "../../types";
import extraIcon from "./extraIcon.vue";
import { ReText } from "@/components/ReText";
import { useNav } from "@/layout/hooks/useNav";
import { transformI18n } from "@/plugins/i18n";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import { ref, toRaw, PropType, nextTick, computed, CSSProperties } from "vue";
import {
type PropType,
type CSSProperties,
ref,
toRaw,
computed,
useAttrs
} from "vue";
import ArrowUp from "@iconify-icons/ep/arrow-up-bold";
import EpArrowDown from "@iconify-icons/ep/arrow-down-bold";
import ArrowLeft from "@iconify-icons/ep/arrow-left-bold";
import ArrowRight from "@iconify-icons/ep/arrow-right-bold";
const attrs = useAttrs();
const { layout, isCollapse, tooltipEffect, getDivStyle } = useNav();
const props = defineProps({
@ -29,29 +39,15 @@ const props = defineProps({
}
});
const getSpanStyle = computed((): CSSProperties => {
return {
width: "100%",
textAlign: "center"
};
});
const getNoDropdownStyle = computed((): CSSProperties => {
return {
width: "100%",
display: "flex",
alignItems: "center"
};
});
const getMenuTextStyle = computed(() => {
return {
overflow: "hidden",
textOverflow: "ellipsis",
outline: "none"
};
});
const getsubMenuIconStyle = computed((): CSSProperties => {
const getSubMenuIconStyle = computed((): CSSProperties => {
return {
display: "flex",
justifyContent: "center",
@ -65,43 +61,6 @@ const getsubMenuIconStyle = computed((): CSSProperties => {
};
});
const getSubTextStyle = computed((): CSSProperties => {
if (!isCollapse.value) {
return {
width: "210px",
display: "inline-block",
overflow: "hidden",
textOverflow: "ellipsis"
};
} else {
return {
width: ""
};
}
});
const getSubMenuDivStyle = computed((): any => {
return item => {
return !isCollapse.value
? {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
overflow: "hidden"
}
: {
width: "100%",
textAlign:
item?.parentId === null
? "center"
: layout.value === "mix" && item?.pathList?.length === 2
? "center"
: ""
};
};
});
const expandCloseIcon = computed(() => {
if (!getConfig()?.MenuArrowIconNoTransition) return "";
return {
@ -113,41 +72,6 @@ const expandCloseIcon = computed(() => {
});
const onlyOneChild: menuType = ref(null);
// showTooltip
const hoverMenuMap = new WeakMap();
// dom
const menuTextRef = ref(null);
function hoverMenu(key) {
// showTooltip退
if (hoverMenuMap.get(key)) return;
nextTick(() => {
//
menuTextRef.value?.scrollWidth > menuTextRef.value?.clientWidth
? Object.assign(key, {
showTooltip: true
})
: Object.assign(key, {
showTooltip: false
});
hoverMenuMap.set(key, true);
});
}
//
function overflowSlice(text, item?: any) {
const newText =
(text?.length > 1 ? text.toString().slice(0, 1) : text) + "...";
if (item && !(isCollapse.value && item?.parentId === null)) {
return layout.value === "mix" &&
item?.pathList?.length === 2 &&
isCollapse.value
? newText
: text;
}
return newText;
}
function hasOneShowingChild(children: menuType[] = [], parent: menuType) {
const showingChildren = children.filter((item: any) => {
@ -182,19 +106,23 @@ function resolvePath(routePath) {
</script>
<template>
<el-menu-item
<link-item
v-if="
hasOneShowingChild(props.item.children, props.item) &&
(!onlyOneChild.children || onlyOneChild.noShowingChildren)
"
:to="item"
>
<el-menu-item
:index="resolvePath(onlyOneChild.path)"
:class="{ 'submenu-title-noDropdown': !isNest }"
:style="getNoDropdownStyle"
v-bind="attrs"
>
<div
v-if="toRaw(props.item.meta.icon)"
class="sub-menu-icon"
:style="getsubMenuIconStyle"
:style="getSubMenuIconStyle"
>
<component
:is="
@ -205,108 +133,83 @@ function resolvePath(routePath) {
"
/>
</div>
<span
<el-text
v-if="
!props.item?.meta.icon &&
(!props.item?.meta.icon &&
isCollapse &&
layout === 'vertical' &&
props.item?.pathList?.length === 1
"
:style="getSpanStyle"
>
{{ overflowSlice(transformI18n(onlyOneChild.meta.title)) }}
</span>
<span
v-if="
!onlyOneChild.meta.icon &&
props.item?.pathList?.length === 1) ||
(!onlyOneChild.meta.icon &&
isCollapse &&
layout === 'mix' &&
props.item?.pathList?.length === 2
props.item?.pathList?.length === 2)
"
:style="getSpanStyle"
truncated
class="!px-4 !text-inherit"
>
{{ overflowSlice(transformI18n(onlyOneChild.meta.title)) }}
</span>
{{ transformI18n(onlyOneChild.meta.title) }}
</el-text>
<template #title>
<div :style="getDivStyle">
<span v-if="layout === 'horizontal'">
{{ transformI18n(onlyOneChild.meta.title) }}
</span>
<el-tooltip
v-else
placement="top"
:effect="tooltipEffect"
:offset="-10"
:disabled="!onlyOneChild.showTooltip"
>
<template #content>
{{ transformI18n(onlyOneChild.meta.title) }}
</template>
<span
ref="menuTextRef"
:style="getMenuTextStyle"
@mouseover="hoverMenu(onlyOneChild)"
<ReText
:tippyProps="{
offset: [0, -10],
theme: tooltipEffect
}"
class="!text-inherit"
>
{{ transformI18n(onlyOneChild.meta.title) }}
</span>
</el-tooltip>
</ReText>
<extraIcon :extraIcon="onlyOneChild.meta.extraIcon" />
</div>
</template>
</el-menu-item>
</link-item>
<el-sub-menu
v-else
ref="subMenu"
v-bind="expandCloseIcon"
teleported
:index="resolvePath(props.item.path)"
v-bind="expandCloseIcon"
>
<template #title>
<div
v-if="toRaw(props.item.meta.icon)"
:style="getsubMenuIconStyle"
:style="getSubMenuIconStyle"
class="sub-menu-icon"
>
<component
:is="useRenderIcon(props.item.meta && toRaw(props.item.meta.icon))"
/>
</div>
<span v-if="layout === 'horizontal'">
{{ transformI18n(props.item.meta.title) }}
</span>
<div
:style="getSubMenuDivStyle(props.item)"
<ReText
v-if="
!(
layout === 'vertical' &&
isCollapse &&
toRaw(props.item.meta.icon) &&
props.item.parentId === null
)
"
:tippyProps="{
offset: [0, -10],
theme: tooltipEffect
}"
:class="{
'!text-inherit': true,
'!px-4':
layout !== 'horizontal' &&
isCollapse &&
!toRaw(props.item.meta.icon) &&
props.item.parentId === null
}"
>
<el-tooltip
v-if="layout !== 'horizontal'"
placement="top"
:effect="tooltipEffect"
:offset="-10"
:disabled="!props.item.showTooltip"
>
<template #content>
{{ transformI18n(props.item.meta.title) }}
</template>
<span
ref="menuTextRef"
:style="getSubTextStyle"
@mouseover="hoverMenu(props.item)"
>
{{
overflowSlice(transformI18n(props.item.meta.title), props.item)
}}
</span>
</el-tooltip>
</ReText>
<extraIcon v-if="!isCollapse" :extraIcon="props.item.meta.extraIcon" />
</div>
</template>
<sidebar-item
v-for="child in props.item.children"
:key="child.path"

15
src/layout/components/sidebar/vertical.vue

@ -18,7 +18,14 @@ const showLogo = ref(
)?.showLogo ?? true
);
const { device, pureApp, isCollapse, menuSelect, toggleSideBar } = useNav();
const {
device,
pureApp,
isCollapse,
tooltipEffect,
menuSelect,
toggleSideBar
} = useNav();
const subMenuData = ref([]);
@ -80,7 +87,7 @@ onBeforeUnmount(() => {
<template>
<div
v-loading="loading"
:class="['sidebar-container', showLogo ? 'has-logo' : '']"
:class="['sidebar-container', showLogo ? 'has-logo' : 'no-logo']"
>
<Logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar
@ -91,10 +98,12 @@ onBeforeUnmount(() => {
router
unique-opened
mode="vertical"
popper-class="pure-scrollbar"
class="outer-most select-none"
:collapse="isCollapse"
:default-active="defaultActive"
:collapse-transition="false"
:popper-effect="tooltipEffect"
:default-active="defaultActive"
>
<sidebar-item
v-for="routes in menuData"

93
src/layout/components/tag/index.scss

@ -18,26 +18,6 @@
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes close {
from {
transform: translate(-50%, -50%);
}
to {
transform: translate(0, -50%);
}
}
.tags-view {
position: relative;
display: flex;
@ -51,46 +31,40 @@
.scroll-item {
position: relative;
display: inline-block;
height: 28px;
padding: 0 6px;
margin-right: 4px;
line-height: 28px;
height: 34px;
padding-left: 6px;
line-height: 34px;
cursor: pointer;
border-radius: 3px 3px 0 0;
box-shadow: 0 0 1px #888;
transition: all 0.4s;
&:not(:first-child) {
padding-right: 24px;
}
.el-icon-close {
position: absolute;
top: 50%;
font-size: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--el-color-primary);
cursor: pointer;
transition: font-size 0.2s;
transform: translate(-50%, -50%);
&:hover {
font-size: 13px;
color: #fff;
background: #b4bccc;
border-radius: 50%;
}
}
border-radius: 4px;
transition:
background-color 0.12s,
color 0.12s;
transform: translate(0, -50%);
&.is-closable:not(:first-child) {
&:hover {
padding-right: 18px;
&:not(.is-active) {
.el-icon-close {
animation: close 200ms ease-in forwards;
}
}
color: rgb(0 0 0 / 88%) !important;
background-color: rgb(0 0 0 / 6%);
}
}
}
a {
.tag-title {
padding: 0 4px;
color: var(--el-text-color-primary);
text-decoration: none;
@ -99,7 +73,6 @@
.scroll-container {
position: relative;
flex: 1;
padding: 5px 0;
overflow: hidden;
white-space: nowrap;
@ -109,13 +82,12 @@
overflow: visible;
white-space: nowrap;
list-style: none;
transition: transform 0.5s ease-in-out;
.scroll-item {
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
&:nth-child(1) {
margin-left: 5px;
padding: 0 12px;
}
}
}
@ -195,16 +167,9 @@
.scroll-item.is-active {
position: relative;
color: #fff;
box-shadow: 0 0 0.7px #888;
&:not(:first-child) {
padding-right: 18px;
}
.el-icon-close {
transform: translate(0, -50%);
}
a {
.tag-title {
color: var(--el-color-primary) !important;
}
}
@ -213,16 +178,16 @@
.arrow-right,
.arrow-down {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 38px;
height: 34px;
color: var(--el-text-color-primary);
svg {
position: absolute;
left: 50%;
width: 20px;
height: 20px;
transform: translate(-50%, 50%);
}
}
@ -247,7 +212,7 @@
.card-in {
color: var(--el-color-primary);
a {
.tag-title {
color: var(--el-color-primary);
}
}
@ -257,7 +222,7 @@
color: #666;
border: none;
a {
.tag-title {
color: #666;
}
}

94
src/layout/components/tag/index.vue

@ -4,21 +4,26 @@ import { emitter } from "@/utils/mitt";
import { RouteConfigs } from "../../types";
import { useTags } from "../../hooks/useTag";
import { routerArrays } from "@/layout/types";
import { useFullscreen, onClickOutside } from "@vueuse/core";
import { handleAliveRoute, getTopMenu } from "@/router/utils";
import { useSettingStoreHook } from "@/store/modules/settings";
import { useResizeObserver, useFullscreen } from "@vueuse/core";
import { isEqual, isAllEmpty, debounce } from "@pureadmin/utils";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { ref, watch, unref, toRaw, nextTick, onBeforeUnmount } from "vue";
import {
delay,
isEqual,
isAllEmpty,
useResizeObserver
} from "@pureadmin/utils";
import ExitFullscreen from "@iconify-icons/ri/fullscreen-exit-fill";
import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
import ArrowDown from "@iconify-icons/ri/arrow-down-s-line";
import ArrowRightSLine from "@iconify-icons/ri/arrow-right-s-line";
import ArrowLeftSLine from "@iconify-icons/ri/arrow-left-s-line";
import CloseBold from "@iconify-icons/ep/close-bold";
const {
Close,
route,
router,
visible,
@ -33,6 +38,7 @@ const {
pureSetting,
activeIndex,
getTabStyle,
isScrolling,
iconIsActive,
linkIsActive,
currentSelect,
@ -49,6 +55,7 @@ const {
const tabDom = ref();
const containerDom = ref();
const scrollbarDom = ref();
const contextmenuRef = ref();
const isShowArrow = ref(false);
const topPath = getTopMenu()?.path;
const { VITE_HIDE_HOME } = import.meta.env;
@ -132,6 +139,38 @@ const handleScroll = (offset: number): void => {
translateX.value = 0;
}
}
isScrolling.value = false;
};
const handleWheel = (event: WheelEvent): void => {
isScrolling.value = true;
const scrollIntensity = Math.abs(event.deltaX) + Math.abs(event.deltaY);
let offset = 0;
if (event.deltaX < 0) {
offset = scrollIntensity > 0 ? scrollIntensity : 100;
} else {
offset = scrollIntensity > 0 ? -scrollIntensity : -100;
}
smoothScroll(offset);
};
const smoothScroll = (offset: number): void => {
//
const scrollAmount = 20;
let remaining = Math.abs(offset);
const scrollStep = () => {
const scrollOffset = Math.sign(offset) * Math.min(scrollAmount, remaining);
handleScroll(scrollOffset);
remaining -= Math.abs(scrollOffset);
if (remaining > 0) {
requestAnimationFrame(scrollStep);
}
};
requestAnimationFrame(scrollStep);
};
function dynamicRouteTag(value: string): void {
@ -142,7 +181,7 @@ function dynamicRouteTag(value: string): void {
function concatPath(arr: object[], value: string) {
if (!hasValue) {
arr.forEach((arrItem: any) => {
if (arrItem.path === value || arrItem.path === value) {
if (arrItem.path === value) {
useMultiTagsStoreHook().handleTags("push", {
path: value,
meta: arrItem.meta,
@ -328,6 +367,7 @@ function handleCommand(command: any) {
/** 触发右键中菜单的点击事件 */
function selectTag(key, item) {
closeMenu();
onClickDrop(key, item, currentSelect.value);
}
@ -421,7 +461,7 @@ function openMenu(tag, e) {
}
currentSelect.value = tag;
const menuMinWidth = 105;
const menuMinWidth = 140;
const offsetLeft = unref(containerDom).getBoundingClientRect().left;
const offsetWidth = unref(containerDom).offsetWidth;
const maxLeft = offsetWidth - menuMinWidth;
@ -462,6 +502,10 @@ function tagOnClick(item) {
// showMenuModel(item?.path, item?.query);
}
onClickOutside(contextmenuRef, closeMenu, {
detectIframe: true
});
watch(route, () => {
activeIndex.value = -1;
dynamicTagView();
@ -497,10 +541,8 @@ onMounted(() => {
});
});
useResizeObserver(
scrollbarDom,
debounce(() => dynamicTagView())
);
useResizeObserver(scrollbarDom, dynamicTagView);
delay().then(() => dynamicTagView());
});
onBeforeUnmount(() => {
@ -512,34 +554,31 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="containerDom" class="tags-view" v-if="!showTags">
<div v-if="!showTags" ref="containerDom" class="tags-view">
<span v-show="isShowArrow" class="arrow-left">
<IconifyIconOffline :icon="ArrowLeftSLine" @click="handleScroll(200)" />
</span>
<div ref="scrollbarDom" class="scroll-container">
<div class="tab select-none" ref="tabDom" :style="getTabStyle">
<div
:ref="'dynamic' + index"
ref="scrollbarDom"
class="scroll-container"
@wheel.prevent="handleWheel"
>
<div ref="tabDom" class="tab select-none" :style="getTabStyle">
<div
v-for="(item, index) in multiTags"
:ref="'dynamic' + index"
:key="index"
:class="[
'scroll-item is-closable',
linkIsActive(item),
route.path === item.path && showModel === 'card'
? 'card-active'
: ''
]"
:class="['scroll-item is-closable', linkIsActive(item)]"
@contextmenu.prevent="openMenu(item, $event)"
@mouseenter.prevent="onMouseenter(index)"
@mouseleave.prevent="onMouseleave(index)"
@click="tagOnClick(item)"
>
<router-link
:to="item.path"
class="dark:!text-text_color_primary dark:hover:!text-primary"
<span
class="tag-title dark:!text-text_color_primary dark:hover:!text-primary"
>
{{ transformI18n(item.meta.title) }}
</router-link>
</span>
<span
v-if="
iconIsActive(item, index) ||
@ -548,11 +587,11 @@ onBeforeUnmount(() => {
class="el-icon-close"
@click.stop="deleteMenu(item)"
>
<IconifyIconOffline :icon="CloseBold" />
<IconifyIconOffline :icon="Close" />
</span>
<div
:ref="'schedule' + index"
<span
v-if="showModel !== 'card'"
:ref="'schedule' + index"
:class="[scheduleIsActive(item)]"
/>
</div>
@ -565,6 +604,7 @@ onBeforeUnmount(() => {
<transition name="el-zoom-in-top">
<ul
v-show="visible"
ref="contextmenuRef"
:key="Math.random()"
:style="getContextMenuStyle"
class="contextmenu"

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save