mirror of https://github.com/certd/certd
feat: 升级前端框架,适配手机端
commit
8fcabc5e9f
|
@ -9,4 +9,4 @@ VITE_APP_COPYRIGHT_URL=https://certd.handsfree.work
|
|||
VITE_APP_LOGO=/static/images/logo/logo.svg
|
||||
VITE_APP_LOGIN_LOGO=/static/images/logo/rect-black.svg
|
||||
VITE_APP_PROJECT_PATH=https://github.com/certd/certd
|
||||
|
||||
VITE_APP_NAMESPACE=fs
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
|
||||
"trailingComma": "none",
|
||||
"printWidth": 160
|
||||
"printWidth": 220
|
||||
}
|
||||
|
|
|
@ -13,6 +13,11 @@ https://github.com/fast-crud/fs-server-js
|
|||
* [fs-admin-naive](https://github.com/fast-crud/fs-admin-naive-ui) naive版示例
|
||||
* [fs-in-vben-starter](https://github.com/fast-crud/fs-in-vben-starter) vben示例
|
||||
|
||||
# build
|
||||
|
||||
```sh
|
||||
set NODE_OPTIONS=--max-old-space-size=32768 && npm run build
|
||||
```
|
||||
# 感谢
|
||||
|
||||
### 依赖
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
import path from "node:path";
|
||||
|
||||
import { addDynamicIconSelectors } from "@iconify/tailwind";
|
||||
import { getPackagesSync } from "@manypkg/get-packages";
|
||||
import typographyPlugin from "@tailwindcss/typography";
|
||||
import animate from "tailwindcss-animate";
|
||||
|
||||
import { enterAnimationPlugin } from "./plugins/entry.mjs";
|
||||
|
||||
// import defaultTheme from 'tailwindcss/defaultTheme';
|
||||
|
||||
const { packages } = getPackagesSync(process.cwd());
|
||||
|
||||
const tailwindPackages = [];
|
||||
|
||||
packages.forEach((pkg) => {
|
||||
// apps目录下和 @vben-core/tailwind-ui 包需要使用到 tailwindcss ui
|
||||
// if (fs.existsSync(path.join(pkg.dir, 'tailwind.config.mjs'))) {
|
||||
tailwindPackages.push(pkg.dir);
|
||||
// }
|
||||
});
|
||||
|
||||
const shadcnUiColors = {
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
hover: "hsl(var(--accent-hover))",
|
||||
lighter: "has(val(--accent-lighter))"
|
||||
},
|
||||
background: {
|
||||
deep: "hsl(var(--background-deep))",
|
||||
DEFAULT: "hsl(var(--background))"
|
||||
},
|
||||
border: {
|
||||
DEFAULT: "hsl(var(--border))"
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))"
|
||||
},
|
||||
destructive: {
|
||||
...createColorsPalette("destructive"),
|
||||
DEFAULT: "hsl(var(--destructive))"
|
||||
},
|
||||
|
||||
foreground: {
|
||||
DEFAULT: "hsl(var(--foreground))"
|
||||
},
|
||||
|
||||
input: {
|
||||
background: "hsl(var(--input-background))",
|
||||
DEFAULT: "hsl(var(--input))"
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))"
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))"
|
||||
},
|
||||
primary: {
|
||||
...createColorsPalette("primary"),
|
||||
DEFAULT: "hsl(var(--primary))"
|
||||
},
|
||||
|
||||
ring: "hsl(var(--ring))",
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
desc: "hsl(var(--secondary-desc))",
|
||||
foreground: "hsl(var(--secondary-foreground))"
|
||||
}
|
||||
};
|
||||
|
||||
const customColors = {
|
||||
green: {
|
||||
...createColorsPalette("green"),
|
||||
foreground: "hsl(var(--success-foreground))"
|
||||
},
|
||||
header: {
|
||||
DEFAULT: "hsl(var(--header))"
|
||||
},
|
||||
heavy: {
|
||||
DEFAULT: "hsl(var(--heavy))",
|
||||
foreground: "hsl(var(--heavy-foreground))"
|
||||
},
|
||||
main: {
|
||||
DEFAULT: "hsl(var(--main))"
|
||||
},
|
||||
overlay: {
|
||||
content: "hsl(var(--overlay-content))",
|
||||
DEFAULT: "hsl(var(--overlay))"
|
||||
},
|
||||
red: {
|
||||
...createColorsPalette("red"),
|
||||
foreground: "hsl(var(--destructive-foreground))"
|
||||
},
|
||||
sidebar: {
|
||||
deep: "hsl(var(--sidebar-deep))",
|
||||
DEFAULT: "hsl(var(--sidebar))"
|
||||
},
|
||||
success: {
|
||||
...createColorsPalette("success"),
|
||||
DEFAULT: "hsl(var(--success))"
|
||||
},
|
||||
warning: {
|
||||
...createColorsPalette("warning"),
|
||||
DEFAULT: "hsl(var(--warning))"
|
||||
},
|
||||
yellow: {
|
||||
...createColorsPalette("yellow"),
|
||||
foreground: "hsl(var(--warning-foreground))"
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
content: ["./index.html", ...tailwindPackages.map((item) => path.join(item, "src/**/*.{vue,js,ts,jsx,tsx,svelte,astro,html}"))],
|
||||
darkMode: "selector",
|
||||
plugins: [animate, typographyPlugin, addDynamicIconSelectors(), enterAnimationPlugin],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px"
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"collapsible-down": "collapsible-down 0.2s ease-in-out",
|
||||
"collapsible-up": "collapsible-up 0.2s ease-in-out",
|
||||
float: "float 5s linear 0ms infinite"
|
||||
},
|
||||
|
||||
animationDuration: {
|
||||
2000: "2000ms",
|
||||
3000: "3000ms"
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
xl: "calc(var(--radius) + 4px)"
|
||||
},
|
||||
boxShadow: {
|
||||
float: `0 6px 16px 0 rgb(0 0 0 / 8%),
|
||||
0 3px 6px -4px rgb(0 0 0 / 12%),
|
||||
0 9px 28px 8px rgb(0 0 0 / 5%)`
|
||||
},
|
||||
colors: {
|
||||
...customColors,
|
||||
...shadcnUiColors
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"var(--font-family)"
|
||||
// ...defaultTheme.fontFamily.sans
|
||||
]
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" }
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" }
|
||||
},
|
||||
"collapsible-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-collapsible-content-height)" }
|
||||
},
|
||||
"collapsible-up": {
|
||||
from: { height: "var(--radix-collapsible-content-height)" },
|
||||
to: { height: "0" }
|
||||
},
|
||||
float: {
|
||||
"0%": { transform: "translateY(0)" },
|
||||
"50%": { transform: "translateY(-20px)" },
|
||||
"100%": { transform: "translateY(0)" }
|
||||
}
|
||||
},
|
||||
zIndex: {
|
||||
100: "100",
|
||||
1000: "1000"
|
||||
}
|
||||
}
|
||||
},
|
||||
safelist: ["dark"]
|
||||
};
|
||||
|
||||
function createColorsPalette(name) {
|
||||
// backgroundLightest: '#EFF6FF', // Tailwind CSS 默认的 `blue-50`
|
||||
// backgroundLighter: '#DBEAFE', // Tailwind CSS 默认的 `blue-100`
|
||||
// backgroundLight: '#BFDBFE', // Tailwind CSS 默认的 `blue-200`
|
||||
// borderLight: '#93C5FD', // Tailwind CSS 默认的 `blue-300`
|
||||
// border: '#60A5FA', // Tailwind CSS 默认的 `blue-400`
|
||||
// main: '#3B82F6', // Tailwind CSS 默认的 `blue-500`
|
||||
// hover: '#2563EB', // Tailwind CSS 默认的 `blue-600`
|
||||
// active: '#1D4ED8', // Tailwind CSS 默认的 `blue-700`
|
||||
// backgroundDark: '#1E40AF', // Tailwind CSS 默认的 `blue-800`
|
||||
// backgroundDarker: '#1E3A8A', // Tailwind CSS 默认的 `blue-900`
|
||||
// backgroundDarkest: '#172554', // Tailwind CSS 默认的 `blue-950`
|
||||
|
||||
// • backgroundLightest (#EFF6FF): 适用于最浅的背景色,可能用于非常轻微的阴影或卡片的背景。
|
||||
// • backgroundLighter (#DBEAFE): 适用于略浅的背景色,通常用于次要背景或略浅的区域。
|
||||
// • backgroundLight (#BFDBFE): 适用于浅色背景,可能用于输入框或表单区域的背景。
|
||||
// • borderLight (#93C5FD): 适用于浅色边框,可能用于输入框或卡片的边框。
|
||||
// • border (#60A5FA): 适用于普通边框,可能用于按钮或卡片的边框。
|
||||
// • main (#3B82F6): 适用于主要的主题色,通常用于按钮、链接或主要的强调色。
|
||||
// • hover (#2563EB): 适用于鼠标悬停状态下的颜色,例如按钮悬停时的背景色或边框色。
|
||||
// • active (#1D4ED8): 适用于激活状态下的颜色,例如按钮按下时的背景色或边框色。
|
||||
// • backgroundDark (#1E40AF): 适用于深色背景,可能用于主要按钮或深色卡片背景。
|
||||
// • backgroundDarker (#1E3A8A): 适用于更深的背景,通常用于头部导航栏或页脚。
|
||||
// • backgroundDarkest (#172554): 适用于最深的背景,可能用于非常深色的区域或极端对比色。
|
||||
|
||||
return {
|
||||
50: `hsl(var(--${name}-50))`,
|
||||
100: `hsl(var(--${name}-100))`,
|
||||
200: `hsl(var(--${name}-200))`,
|
||||
300: `hsl(var(--${name}-300))`,
|
||||
400: `hsl(var(--${name}-400))`,
|
||||
500: `hsl(var(--${name}-500))`,
|
||||
600: `hsl(var(--${name}-600))`,
|
||||
700: `hsl(var(--${name}-700))`,
|
||||
// 800: `hsl(var(--${name}-800))`,
|
||||
// 900: `hsl(var(--${name}-900))`,
|
||||
// 950: `hsl(var(--${name}-950))`,
|
||||
// 激活状态下的颜色,适用于按钮按下时的背景色或边框色。
|
||||
active: `hsl(var(--${name}-700))`,
|
||||
// 浅色背景,适用于输入框或表单区域的背景。
|
||||
"background-light": `hsl(var(--${name}-200))`,
|
||||
// 适用于略浅的背景色,通常用于次要背景或略浅的区域。
|
||||
"background-lighter": `hsl(var(--${name}-100))`,
|
||||
// 最浅的背景色,适用于非常轻微的阴影或卡片的背景。
|
||||
"background-lightest": `hsl(var(--${name}-50))`,
|
||||
// 适用于普通边框,可能用于按钮或卡片的边框。
|
||||
border: `hsl(var(--${name}-400))`,
|
||||
// 浅色边框,适用于输入框或卡片的边框。
|
||||
"border-light": `hsl(var(--${name}-300))`,
|
||||
foreground: `hsl(var(--${name}-foreground))`,
|
||||
// 鼠标悬停状态下的颜色,适用于按钮悬停时的背景色或边框色。
|
||||
hover: `hsl(var(--${name}-600))`,
|
||||
// 主色文本
|
||||
text: `hsl(var(--${name}-500))`,
|
||||
// 主色文本激活态
|
||||
"text-active": `hsl(var(--${name}-700))`,
|
||||
// 主色文本悬浮态
|
||||
"text-hover": `hsl(var(--${name}-600))`
|
||||
};
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import plugin from "tailwindcss/plugin.js";
|
||||
|
||||
const enterAnimationPlugin = plugin(({ addUtilities }) => {
|
||||
const maxChild = 5;
|
||||
const utilities = {};
|
||||
for (let i = 1; i <= maxChild; i++) {
|
||||
const baseDelay = 0.1;
|
||||
const delay = `${baseDelay * i}s`;
|
||||
|
||||
utilities[`.enter-x:nth-child(${i})`] = {
|
||||
animation: `enter-x-animation 0.3s ease-in-out ${delay} forwards`,
|
||||
opacity: "0",
|
||||
transform: `translateX(50px)`
|
||||
};
|
||||
|
||||
utilities[`.enter-y:nth-child(${i})`] = {
|
||||
animation: `enter-y-animation 0.3s ease-in-out ${delay} forwards`,
|
||||
opacity: "0",
|
||||
transform: `translateY(50px)`
|
||||
};
|
||||
|
||||
utilities[`.-enter-x:nth-child(${i})`] = {
|
||||
animation: `enter-x-animation 0.3s ease-in-out ${delay} forwards`,
|
||||
opacity: "0",
|
||||
transform: `translateX(-50px)`
|
||||
};
|
||||
|
||||
utilities[`.-enter-y:nth-child(${i})`] = {
|
||||
animation: `enter-y-animation 0.3s ease-in-out ${delay} forwards`,
|
||||
opacity: "0",
|
||||
transform: `translateY(-50px)`
|
||||
};
|
||||
}
|
||||
|
||||
// 添加动画关键帧
|
||||
addUtilities(utilities);
|
||||
addUtilities({
|
||||
"@keyframes enter-x-animation": {
|
||||
to: {
|
||||
opacity: "1",
|
||||
transform: "translateX(0)"
|
||||
}
|
||||
},
|
||||
"@keyframes enter-y-animation": {
|
||||
to: {
|
||||
opacity: "1",
|
||||
transform: "translateY(0)"
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export { enterAnimationPlugin };
|
|
@ -0,0 +1,15 @@
|
|||
import config from ".";
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}),
|
||||
// Specifying the config is not necessary in most cases, but it is included
|
||||
autoprefixer: {},
|
||||
// 修复 element-plus 和 ant-design-vue 的样式和tailwindcss冲突问题
|
||||
"postcss-antd-fixes": { prefixes: ["ant", "el"] },
|
||||
"postcss-import": {},
|
||||
"postcss-preset-env": {},
|
||||
tailwindcss: { config },
|
||||
"tailwindcss/nesting": {}
|
||||
}
|
||||
};
|
|
@ -9,7 +9,7 @@
|
|||
"debug": "vite --mode debug --open",
|
||||
"debug:pm": "vite --mode debugpm",
|
||||
"debug:force": "vite --force --mode debug",
|
||||
"build": " vite build ",
|
||||
"build": "vite build ",
|
||||
"dev-build": "echo 1",
|
||||
"test:unit": "vitest",
|
||||
"serve": "vite preview",
|
||||
|
@ -21,49 +21,78 @@
|
|||
"circle:check": "pnpm dependency-cruise --validate --output-type err-html -f dependency-report.html src",
|
||||
"afterPubPush": "git add . && git commit -m \"build: publish success\" && git push"
|
||||
},
|
||||
"author": "Greper",
|
||||
"author": "greper",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^7.0.2",
|
||||
"@ant-design/icons-vue": "^6.1.0",
|
||||
"@fast-crud/fast-crud": "^1.25.0",
|
||||
"@fast-crud/fast-extends": "^1.25.0",
|
||||
"@fast-crud/ui-antdv4": "^1.25.0",
|
||||
"@fast-crud/ui-interface": "^1.25.0",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@aws-sdk/client-s3": "^3.535.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.535.0",
|
||||
"@ctrl/tinycolor": "^4.1.0",
|
||||
"@fast-crud/fast-crud": "^1.25.4",
|
||||
"@fast-crud/fast-extends": "^1.25.4",
|
||||
"@fast-crud/ui-antdv4": "^1.25.4",
|
||||
"@fast-crud/ui-interface": "^1.25.4",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@manypkg/get-packages": "^2.2.2",
|
||||
"@soerenmartius/vue3-clipboard": "^0.1.2",
|
||||
"@vue-js-cron/light": "^4.0.5",
|
||||
"ant-design-vue": "^4.1.2",
|
||||
"async-validator": "^4.2.5",
|
||||
"axios": "^1.7.2",
|
||||
"@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/vue-store": "^0.7.0",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vue/shared": "^3.5.13",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios-mock-adapter": "^1.22.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"better-scroll": "^2.5.1",
|
||||
"china-division": "^2.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.36.0",
|
||||
"cos-js-sdk-v5": "^1.7.0",
|
||||
"cron-parser": "^4.9.0",
|
||||
"cropperjs": "^1.6.1",
|
||||
"cssnano": "^7.0.6",
|
||||
"dayjs": "^1.11.7",
|
||||
"echarts": "^5.5.1",
|
||||
"defu": "^6.1.4",
|
||||
"highlight.js": "^11.9.0",
|
||||
"humanize-duration": "^3.27.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-vue-next": "^0.477.0",
|
||||
"mitt": "^3.0.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"pinia": "2.1.7",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"postcss-antd-fixes": "^0.2.0",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-preset-env": "^10.1.5",
|
||||
"psl": "^1.9.0",
|
||||
"qiniu-js": "^3.4.2",
|
||||
"radix-vue": "^1.9.16",
|
||||
"sortablejs": "^1.15.3",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"theme-colors": "^0.1.0",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vitest": "^0.34.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"sortablejs": "^1.15.2",
|
||||
"vue": "^3.4.21",
|
||||
"vue-cropperjs": "^5.0.0",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-i18n": "^9.10.2",
|
||||
"vue-router": "^4.3.0",
|
||||
"vuedraggable": "^4.1.0"
|
||||
"vuedraggable": "^4.1.0",
|
||||
"watermark-js-plus": "^1.5.8",
|
||||
"zod": "^3.24.2",
|
||||
"zod-defaults": "^0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@certd/lib-iframe": "^1.30.6",
|
||||
|
@ -83,7 +112,7 @@
|
|||
"@vue/compiler-sfc": "^3.4.21",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"caller-path": "^4.0.0",
|
||||
"chai": "^5.1.0",
|
||||
"dependency-cruiser": "^16.2.3",
|
||||
|
@ -105,7 +134,7 @@
|
|||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"stylelint": "^15.11.0",
|
||||
"stylelint-order": "^6.0.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"terser": "^5.29.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslint": "^6.1.3",
|
||||
|
@ -114,6 +143,7 @@
|
|||
"vite": "^5.3.1",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-theme": "^0.8.6",
|
||||
"vite-plugin-windicss": "^1.9.3",
|
||||
"vitest": "^2.1.2",
|
||||
"vue-eslint-parser": "^9.4.2",
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
// tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import config from "./build/tailwind-config/index.mjs";
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}),
|
||||
// Specifying the config is not necessary in most cases, but it is included
|
||||
autoprefixer: {},
|
||||
// 修复 element-plus 和 ant-design-vue 的样式和tailwindcss冲突问题
|
||||
"postcss-antd-fixes": { prefixes: ["ant", "el"] },
|
||||
"postcss-import": {},
|
||||
"postcss-preset-env": {},
|
||||
tailwindcss: { config },
|
||||
"tailwindcss/nesting": {}
|
||||
}
|
||||
};
|
|
@ -1,21 +1,23 @@
|
|||
<template>
|
||||
<a-config-provider :locale="locale" :theme="settingStore.themeToken">
|
||||
<AConfigProvider :locale="locale" :theme="tokenTheme">
|
||||
<contextHolder />
|
||||
<fs-form-provider>
|
||||
<router-view v-if="routerEnabled" />
|
||||
<router-view />
|
||||
</fs-form-provider>
|
||||
</a-config-provider>
|
||||
</AConfigProvider>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import zhCN from "ant-design-vue/es/locale/zh_CN";
|
||||
import enUS from "ant-design-vue/es/locale/en_US";
|
||||
import { provide, ref } from "vue";
|
||||
import { usePageStore } from "/src/store/modules/page";
|
||||
import { useSettingStore } from "/@/store/modules/settings";
|
||||
import { computed, provide, ref } from "vue";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import "dayjs/locale/en";
|
||||
import dayjs from "dayjs";
|
||||
import { usePreferences, preferences } from "/@/vben/preferences";
|
||||
import { useAntdDesignTokens } from "/@/vben/hooks";
|
||||
import { theme } from "ant-design-vue";
|
||||
import AConfigProvider from "ant-design-vue/es/config-provider";
|
||||
import { Modal } from "ant-design-vue";
|
||||
|
||||
defineOptions({
|
||||
|
@ -24,13 +26,12 @@ defineOptions({
|
|||
const [modal, contextHolder] = Modal.useModal();
|
||||
provide("modal", modal);
|
||||
//刷新页面方法
|
||||
const routerEnabled = ref(true);
|
||||
const locale = ref(zhCN);
|
||||
async function reload() {
|
||||
// routerEnabled.value = false;
|
||||
// await nextTick();
|
||||
// routerEnabled.value = true;
|
||||
}
|
||||
async function reload() {}
|
||||
localeChanged("zh-cn");
|
||||
provide("fn:router.reload", reload);
|
||||
provide("fn:locale.changed", localeChanged);
|
||||
//刷新页面方法
|
||||
function localeChanged(value: any) {
|
||||
console.log("locale changed:", value);
|
||||
if (value === "zh-cn") {
|
||||
|
@ -45,10 +46,27 @@ localeChanged("zh-cn");
|
|||
provide("fn:router.reload", reload);
|
||||
provide("fn:locale.changed", localeChanged);
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { tokens } = useAntdDesignTokens();
|
||||
|
||||
const tokenTheme = computed(() => {
|
||||
const algorithm = isDark.value ? [theme.darkAlgorithm] : [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
algorithm.push(theme.compactAlgorithm);
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
token: tokens
|
||||
};
|
||||
});
|
||||
//其他初始化
|
||||
// const resourceStore = useResourceStore();
|
||||
// resourceStore.init();
|
||||
const pageStore = usePageStore();
|
||||
pageStore.init();
|
||||
const settingStore = useSettingStore();
|
||||
// const pageStore = usePageStore();
|
||||
// pageStore.init();
|
||||
// const settingStore = useSettingStore();
|
||||
// settingStore.init();
|
||||
</script>
|
||||
|
|
|
@ -25,7 +25,7 @@ export interface UserInfoRes {
|
|||
id: string | number;
|
||||
username: string;
|
||||
nickName: string;
|
||||
avatar: string;
|
||||
avatar?: string;
|
||||
roleIds: number[];
|
||||
isWeak?: boolean;
|
||||
}
|
||||
|
|
|
@ -62,5 +62,6 @@ export default defineComponent({
|
|||
.fs-highlight {
|
||||
margin: 0px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<div class="flex flex-between full-w">
|
||||
<div class="flex">
|
||||
<span v-if="!settingStore.isComm">
|
||||
<span>Powered by</span>
|
||||
<a> handsfree.work </a>
|
||||
</span>
|
||||
|
||||
<template v-if="siteInfo.licenseTo">
|
||||
<a-divider type="vertical" />
|
||||
<a :href="siteInfo.licenseToUrl || ''">{{ siteInfo.licenseTo }}</a>
|
||||
</template>
|
||||
|
||||
<template v-if="sysPublic.icpNo">
|
||||
<a-divider type="vertical" />
|
||||
<span>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank">{{ sysPublic.icpNo }}</a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="ml-5">v{{ version }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useSettingStore } from "/@/store/modules/settings";
|
||||
|
||||
defineOptions({
|
||||
name: "Footer"
|
||||
});
|
||||
const version = ref(import.meta.env.VITE_APP_VERSION);
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
const sysPublic = computed(() => {
|
||||
return settingStore.sysPublic;
|
||||
});
|
||||
const siteInfo = computed(() => {
|
||||
return settingStore.siteInfo;
|
||||
});
|
||||
</script>
|
|
@ -25,14 +25,14 @@
|
|||
<script lang="ts">
|
||||
import i18n from "../../../i18n";
|
||||
import { computed, inject } from "vue";
|
||||
import * as _ from "lodash-es";
|
||||
import { forEach } from "lodash-es";
|
||||
export default {
|
||||
name: "FsLocale",
|
||||
setup() {
|
||||
const languages = computed(() => {
|
||||
const map: any = i18n.global.messages?.value || {};
|
||||
const list: any = [];
|
||||
_.forEach(map, (item, key) => {
|
||||
forEach(map, (item, key) => {
|
||||
list.push({
|
||||
key,
|
||||
label: item.label
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts" setup>
|
||||
import { BasicLayout, LockScreen, UserDropdown } from "/@/vben/layouts";
|
||||
|
||||
import { computed, onErrorCaptured, onMounted } from "vue";
|
||||
import { preferences } from "/@/vben/preferences";
|
||||
import { useAccessStore } from "/@/vben/stores";
|
||||
import { useUserStore } from "/@/store/modules/user";
|
||||
import VipButton from "/@/components/vip-button/index.vue";
|
||||
import TutorialButton from "/@/components/tutorial/index.vue";
|
||||
import { useSettingStore } from "/@/store/modules/settings";
|
||||
import Footer from "./components/footer/index.vue";
|
||||
const userStore = useUserStore();
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
const menus = computed(() => [
|
||||
// {
|
||||
// handler: () => {
|
||||
// openWindow(VBEN_DOC_URL, {
|
||||
// target: "_blank"
|
||||
// });
|
||||
// },
|
||||
// icon: BookOpenText,
|
||||
// text: $t("ui.widgets.document")
|
||||
// }
|
||||
]);
|
||||
|
||||
const avatar = computed(() => {
|
||||
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await userStore.logout(true);
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
const sysPublic = computed(() => {
|
||||
return settingStore.sysPublic;
|
||||
});
|
||||
const siteInfo = computed(() => {
|
||||
return settingStore.siteInfo;
|
||||
});
|
||||
|
||||
onErrorCaptured((e) => {
|
||||
console.error("ErrorCaptured:", e);
|
||||
// notification.error({ message: e.message });
|
||||
//阻止错误向上传递
|
||||
return false;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await settingStore.checkUrlBound();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicLayout @clear-preferences-and-logout="handleLogout">
|
||||
<template #user-dropdown>
|
||||
<UserDropdown :avatar :menus :text="userStore.userInfo?.nickName" description="development@handsfree.work" tag-text="Pro" @logout="handleLogout" />
|
||||
</template>
|
||||
<template #lock-screen>
|
||||
<LockScreen :avatar @to-login="handleLogout" />
|
||||
</template>
|
||||
<template #header-right-0>
|
||||
<div class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full p-1.5 pl-3 pr-3">
|
||||
<tutorial-button v-if="!settingStore.isComm" class="flex-center header-btn" />
|
||||
</div>
|
||||
<div class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full p-1.5 pl-3 pr-3">
|
||||
<vip-button class="flex-center header-btn" mode="nav" />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Footer></Footer>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
.header-btn {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
|
@ -73,6 +73,8 @@ const sysPublic: Ref<SysPublicSetting> = computed(() => {
|
|||
background-size: 100%;
|
||||
//padding: 50px 0 84px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.user-layout-content {
|
||||
height: 100%;
|
||||
|
|
|
@ -1,22 +1,43 @@
|
|||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import Antd from "ant-design-vue";
|
||||
// import Antd from "ant-design-vue";
|
||||
import Antd from "./plugin/antdv-async/index";
|
||||
import "./style/common.less";
|
||||
|
||||
import i18n from "./i18n";
|
||||
import store from "./store";
|
||||
import components from "./components";
|
||||
import router from "./router";
|
||||
import plugin from "./plugin/";
|
||||
// 正式项目请删除mock,避免影响性能
|
||||
//import "./mock";
|
||||
|
||||
import { setupVben } from "./vben";
|
||||
import { util } from "/@/utils";
|
||||
import { initPreferences } from "/@/vben/preferences";
|
||||
// @ts-ignore
|
||||
const app = createApp(App);
|
||||
app.use(Antd);
|
||||
app.use(router);
|
||||
app.use(i18n);
|
||||
app.use(store);
|
||||
app.use(components);
|
||||
app.use(plugin, { i18n });
|
||||
app.mount("#app");
|
||||
async function bootstrap() {
|
||||
const app = createApp(App);
|
||||
// app.use(Antd);
|
||||
app.use(Antd);
|
||||
await setupVben(app);
|
||||
app.use(router);
|
||||
app.use(i18n);
|
||||
// app.use(store);
|
||||
app.use(components);
|
||||
app.use(plugin, { i18n });
|
||||
|
||||
const envMode = util.env.MODE;
|
||||
const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${envMode}`;
|
||||
|
||||
// app偏好设置初始化
|
||||
await initPreferences({
|
||||
namespace,
|
||||
overrides: {
|
||||
app: {
|
||||
name: import.meta.env.VITE_APP_TITLE
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.mount("#app");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import * as _ from "lodash-es";
|
||||
import { cloneDeep, mergeWith, isArray } from "lodash-es";
|
||||
function copyList(originList: any, newList: any, options: any, parentId?: any) {
|
||||
for (const item of originList) {
|
||||
const newItem: any = _.cloneDeep(item);
|
||||
const newItem: any = cloneDeep(item);
|
||||
if (parentId != null && newItem.parentId == null) {
|
||||
newItem.parentId = parentId;
|
||||
}
|
||||
|
@ -218,7 +218,7 @@ const mockUtil: any = {
|
|||
return {
|
||||
code: 0,
|
||||
msg: "success",
|
||||
data: _.cloneDeep(req.body)
|
||||
data: cloneDeep(req.body)
|
||||
};
|
||||
}
|
||||
},
|
||||
|
@ -228,12 +228,12 @@ const mockUtil: any = {
|
|||
handle(req: any): any {
|
||||
const item = findById(req.body.id, list);
|
||||
if (item) {
|
||||
_.mergeWith(item, req.body, (objValue: any, srcValue: any) => {
|
||||
mergeWith(item, req.body, (objValue: any, srcValue: any) => {
|
||||
if (srcValue == null) {
|
||||
return;
|
||||
}
|
||||
// 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空
|
||||
if (_.isArray(objValue)) {
|
||||
if (isArray(objValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
|
@ -305,12 +305,12 @@ const mockUtil: any = {
|
|||
console.log("req", req);
|
||||
let item = findById(req.body.id, list);
|
||||
if (item) {
|
||||
_.mergeWith(item, { [req.body.key]: req.body.value }, (objValue: any, srcValue: any) => {
|
||||
mergeWith(item, { [req.body.key]: req.body.value }, (objValue: any, srcValue: any) => {
|
||||
if (srcValue == null) {
|
||||
return;
|
||||
}
|
||||
// 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空
|
||||
if (_.isArray(objValue)) {
|
||||
if (isArray(objValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
|
@ -336,12 +336,12 @@ const mockUtil: any = {
|
|||
for (const item of req.body) {
|
||||
const item2 = findById(item.id, list);
|
||||
if (item2) {
|
||||
_.mergeWith(item2, item, (objValue: any, srcValue: any) => {
|
||||
mergeWith(item2, item, (objValue: any, srcValue: any) => {
|
||||
if (srcValue == null) {
|
||||
return;
|
||||
}
|
||||
// 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空
|
||||
if (_.isArray(objValue)) {
|
||||
if (isArray(objValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -3,7 +3,7 @@ import cascaderData from "./cascader-data";
|
|||
import pcaDataLittle from "./pca-data-little";
|
||||
// @ts-ignore
|
||||
import { TreeNodesLazyLoader, getPcaData } from "./pcas-data";
|
||||
import * as _ from "lodash-es";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
const openStatus = [
|
||||
{ value: "1", label: "打开", color: "success", icon: "ion:radio-button-on" },
|
||||
{ value: "2", label: "停止", color: "cyan" },
|
||||
|
@ -29,7 +29,7 @@ let manyStatus = [
|
|||
];
|
||||
let tempManyStatus: any[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tempManyStatus = tempManyStatus.concat(_.cloneDeep(manyStatus));
|
||||
tempManyStatus = tempManyStatus.concat(cloneDeep(manyStatus));
|
||||
}
|
||||
manyStatus = tempManyStatus;
|
||||
let idIndex = 0;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { mock } from "../api/service";
|
||||
import * as tools from "../api/tools";
|
||||
import * as _ from "lodash-es";
|
||||
import { forEach } from "lodash-es";
|
||||
import { utils } from "@fast-crud/fast-crud";
|
||||
// @ts-ignore
|
||||
const commonMocks: any = import.meta.glob("./common/mock.*.[j|t]s", { eager: true });
|
||||
|
@ -10,13 +10,13 @@ const apiMocks: any = import.meta.glob("../api/modules/*.mock.ts", { eager: true
|
|||
const viewMocks: any = import.meta.glob("../views/**/mock.[j|t]s", { eager: true });
|
||||
|
||||
const list: any = [];
|
||||
_.forEach(commonMocks, (value: any) => {
|
||||
forEach(commonMocks, (value: any) => {
|
||||
list.push(value.default);
|
||||
});
|
||||
_.forEach(apiMocks, (value: any) => {
|
||||
forEach(apiMocks, (value: any) => {
|
||||
list.push(value.default);
|
||||
});
|
||||
_.forEach(viewMocks, (value: any) => {
|
||||
forEach(viewMocks, (value: any) => {
|
||||
list.push(value.default);
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,190 @@
|
|||
import { defineAsyncComponent } from "vue";
|
||||
import Input from "ant-design-vue/es/input/Input";
|
||||
import Button from "ant-design-vue/es/button/button";
|
||||
import Divider from "ant-design-vue/es/divider";
|
||||
import Badge from "ant-design-vue/es/badge";
|
||||
import Empty from "ant-design-vue/es/empty";
|
||||
import Avatar from "ant-design-vue/es/avatar";
|
||||
import Steps from "ant-design-vue/es/steps";
|
||||
import Select from "ant-design-vue/es/select";
|
||||
|
||||
export default {
|
||||
install(app: any) {
|
||||
app.use(Input);
|
||||
app.use(Button);
|
||||
app.component("ADivider", Divider);
|
||||
app.component("ABadge", Badge);
|
||||
app.component("AEmpty", Empty);
|
||||
app.component("AAvatar", Avatar);
|
||||
app.use(Steps);
|
||||
app.use(Select);
|
||||
|
||||
app.component(
|
||||
"AInputPassword",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/input/Password"))
|
||||
);
|
||||
app.component(
|
||||
"AButtonGroup",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/button/button-group"))
|
||||
);
|
||||
app.component(
|
||||
"ARadio",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/radio/Radio"))
|
||||
);
|
||||
app.component(
|
||||
"ARadioGroup",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/radio/Group"))
|
||||
);
|
||||
app.component(
|
||||
"ATable",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/table/Table"))
|
||||
);
|
||||
app.component(
|
||||
"AModal",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/modal/Modal"))
|
||||
);
|
||||
app.component(
|
||||
"AForm",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/form/Form"))
|
||||
);
|
||||
app.component(
|
||||
"AFormItem",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/form/FormItem"))
|
||||
);
|
||||
app.component(
|
||||
"AFormItemRest",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/form/FormItemContext"))
|
||||
);
|
||||
|
||||
app.component(
|
||||
"ATabs",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/tabs/src/Tabs"))
|
||||
);
|
||||
|
||||
app.component(
|
||||
"ATabPane",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/tabs/src/TabPanelList/TabPane"))
|
||||
);
|
||||
app.component(
|
||||
"ATextarea",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/input/TextArea"))
|
||||
);
|
||||
app.component(
|
||||
"AInputNumber",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/input-number/index"))
|
||||
);
|
||||
app.component(
|
||||
"ADrawer",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/drawer/index"))
|
||||
);
|
||||
app.component(
|
||||
"ASwitch",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/switch/index"))
|
||||
);
|
||||
app.component(
|
||||
"AUpload",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/upload/index"))
|
||||
);
|
||||
app.component(
|
||||
"ADatePicker",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/date-picker/index"))
|
||||
);
|
||||
app.component(
|
||||
"ARangePicker",
|
||||
defineAsyncComponent(async () => {
|
||||
const { RangePicker } = await import("ant-design-vue/es/date-picker/index");
|
||||
return RangePicker;
|
||||
})
|
||||
);
|
||||
app.component(
|
||||
"ATimePicker",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/time-picker/index"))
|
||||
);
|
||||
app.component(
|
||||
"ATag",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/tag/index"))
|
||||
);
|
||||
app.component(
|
||||
"AAlert",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/alert/index"))
|
||||
);
|
||||
app.component(
|
||||
"AInputAutoComplete",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/auto-complete/index"))
|
||||
);
|
||||
app.component(
|
||||
"ACard",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/card/index"))
|
||||
);
|
||||
app.component(
|
||||
"ACascader",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/cascader/index"))
|
||||
);
|
||||
app.component(
|
||||
"ACheckbox",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/checkbox"))
|
||||
);
|
||||
app.component(
|
||||
"ACheckboxGroup",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/checkbox/Group"))
|
||||
);
|
||||
app.component(
|
||||
"ACol",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/col"))
|
||||
);
|
||||
app.component(
|
||||
"ARow",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/row"))
|
||||
);
|
||||
app.component(
|
||||
"ADropdown",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/dropdown"))
|
||||
);
|
||||
app.component(
|
||||
"AGrid",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/grid"))
|
||||
);
|
||||
app.component(
|
||||
"AImage",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/image"))
|
||||
);
|
||||
app.component(
|
||||
"APagination",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/pagination"))
|
||||
);
|
||||
app.component(
|
||||
"ATooltip",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/tooltip"))
|
||||
);
|
||||
app.component(
|
||||
"ATree",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/tree"))
|
||||
);
|
||||
app.component(
|
||||
"ATreeSelect",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/tree-select"))
|
||||
);
|
||||
app.component(
|
||||
"AToar",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/tree-select"))
|
||||
);
|
||||
|
||||
app.component(
|
||||
"AMenu",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/menu/index"))
|
||||
);
|
||||
app.component(
|
||||
"ASubMenu",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/menu/src/SubMenu"))
|
||||
);
|
||||
app.component(
|
||||
"AMenuItem",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/menu/src/MenuItem"))
|
||||
);
|
||||
|
||||
app.component(
|
||||
"AProgress",
|
||||
defineAsyncComponent(() => import("ant-design-vue/es/progress"))
|
||||
);
|
||||
}
|
||||
};
|
|
@ -29,10 +29,11 @@ import {
|
|||
import "@fast-crud/fast-extends/dist/style.css";
|
||||
import UiAntdv from "@fast-crud/ui-antdv4";
|
||||
import "@fast-crud/ui-antdv4/dist/style.css";
|
||||
import * as _ from "lodash-es";
|
||||
import { merge } from "lodash-es";
|
||||
import { useCrudPermission } from "../permission";
|
||||
import { App } from "vue";
|
||||
import { notification } from "ant-design-vue";
|
||||
import { usePreferences } from "/@/vben/preferences";
|
||||
|
||||
function install(app: App, options: any = {}) {
|
||||
app.use(UiAntdv);
|
||||
|
@ -54,7 +55,18 @@ function install(app: App, options: any = {}) {
|
|||
commonOptions(props: UseCrudProps): CrudOptions {
|
||||
utils.logger.debug("commonOptions:", props);
|
||||
const crudBinding = props.crudExpose?.crudBinding;
|
||||
const { isMobile } = usePreferences();
|
||||
const opts: CrudOptions = {
|
||||
settings: {
|
||||
plugins: {
|
||||
mobile: {
|
||||
enabled: true,
|
||||
props: {
|
||||
isMobile: isMobile
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
table: {
|
||||
scroll: {
|
||||
x: 960
|
||||
|
@ -90,6 +102,7 @@ function install(app: App, options: any = {}) {
|
|||
}
|
||||
},
|
||||
rowHandle: {
|
||||
fixed: "right",
|
||||
buttons: {
|
||||
view: { type: "link", text: null, icon: "ion:eye-outline" },
|
||||
copy: { show: true, type: "link", text: null, icon: "ion:copy-outline" },
|
||||
|
@ -174,6 +187,20 @@ function install(app: App, options: any = {}) {
|
|||
order: 999999,
|
||||
width: -1
|
||||
}
|
||||
},
|
||||
//最后一列空白,用于自动伸缩列宽
|
||||
__blank__: {
|
||||
title: "",
|
||||
type: "text",
|
||||
form: {
|
||||
show: false
|
||||
},
|
||||
column: {
|
||||
order: 99999,
|
||||
width: -1,
|
||||
columnSetShow: false,
|
||||
resizable: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -198,7 +225,11 @@ function install(app: App, options: any = {}) {
|
|||
action: "/basic/file/upload",
|
||||
name: "file",
|
||||
withCredentials: false,
|
||||
uploadRequest: async ({ action, file, onProgress }: any) => {
|
||||
test: 22,
|
||||
custom: { aaa: 22 },
|
||||
uploadRequest: async (opts: any) => {
|
||||
console.log("uploadRequest:", opts);
|
||||
const { action, file, onProgress } = opts;
|
||||
// @ts-ignore
|
||||
const data = new FormData();
|
||||
data.append("file", file);
|
||||
|
@ -268,7 +299,7 @@ function install(app: App, options: any = {}) {
|
|||
// 比如你可以定义一个readonly的公共属性,处理该字段只读,不能编辑
|
||||
if (columnProps.readonly) {
|
||||
// 合并column配置
|
||||
_.merge(columnProps, {
|
||||
merge(columnProps, {
|
||||
form: { show: false },
|
||||
viewForm: { show: true }
|
||||
});
|
||||
|
@ -277,6 +308,25 @@ function install(app: App, options: any = {}) {
|
|||
}
|
||||
});
|
||||
|
||||
//默认宽度,支持自动拖动调整列宽
|
||||
registerMergeColumnPlugin({
|
||||
name: "resize-column-plugin",
|
||||
order: 2,
|
||||
handle: (columnProps: ColumnCompositionProps) => {
|
||||
if (!columnProps.column) {
|
||||
columnProps.column = {};
|
||||
}
|
||||
if (columnProps.column.resizable == null) {
|
||||
columnProps.column.resizable = true;
|
||||
if (!columnProps.column.width) {
|
||||
columnProps.column.width = 200;
|
||||
}
|
||||
}
|
||||
|
||||
return columnProps;
|
||||
}
|
||||
});
|
||||
|
||||
registerMergeColumnPlugin({
|
||||
name: "resize-column-plugin",
|
||||
order: 2,
|
||||
|
|
|
@ -6,7 +6,7 @@ import { message } from "ant-design-vue";
|
|||
import NProgress from "nprogress";
|
||||
export function registerRouterHook() {
|
||||
// 注册路由beforeEach钩子,在第一次加载路由页面时,加载权限
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
router.beforeEach(async (to, from) => {
|
||||
const permissionStore = usePermissionStore();
|
||||
if (permissionStore.isInited) {
|
||||
if (to.meta.permission) {
|
||||
|
@ -20,15 +20,13 @@ export function registerRouterHook() {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
next();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const token = userStore.getToken;
|
||||
if (!token || token === "undefined") {
|
||||
next();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 初始化权限列表
|
||||
|
@ -36,10 +34,10 @@ export function registerRouterHook() {
|
|||
console.log("permission is enabled");
|
||||
await permissionStore.loadFromRemote();
|
||||
console.log("PM load success");
|
||||
next({ ...to, replace: true });
|
||||
return { ...to, replace: true };
|
||||
} catch (e) {
|
||||
console.error("加载动态路由失败", e);
|
||||
next();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { useResourceStore } from "/src/store/modules/resource";
|
||||
// import { useResourceStore } from "/src/store/modules/resource";
|
||||
import { getPermissions } from "./api";
|
||||
import { mitter } from "/@/utils/util.mitt";
|
||||
import { env } from "/@/utils/util.env";
|
||||
import { useAccessStore } from "/@/vben/stores";
|
||||
|
||||
//监听注销事件
|
||||
mitter.on("app.logout", () => {
|
||||
|
@ -75,8 +76,8 @@ export const usePermissionStore = defineStore({
|
|||
this.init({ permissions });
|
||||
|
||||
//过滤没有权限的菜单
|
||||
const resourceStore = useResourceStore();
|
||||
resourceStore.filterByPermission(permissions);
|
||||
const accessStore = useAccessStore();
|
||||
accessStore.setAccessCodes(permissions);
|
||||
},
|
||||
async loadFromRemote() {
|
||||
let permissionTree = [];
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { usePermission } from "/@/plugin/permission";
|
||||
import * as _ from "lodash-es";
|
||||
import { merge as LodashMerge } from "lodash-es";
|
||||
|
||||
export type UseCrudPermissionExtraProps = {
|
||||
hasActionPermission: (action: string) => boolean;
|
||||
|
@ -30,7 +30,7 @@ export function useCrudPermission({ permission }: UseCrudPermissionProps) {
|
|||
return hasPermissions(prefix + ":" + action);
|
||||
}
|
||||
|
||||
function buildCrudPermission() {
|
||||
function buildCrudPermission(): any {
|
||||
if (permission == null) {
|
||||
return {};
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ export function useCrudPermission({ permission }: UseCrudPermissionProps) {
|
|||
}
|
||||
}
|
||||
|
||||
return _.merge(
|
||||
return LodashMerge(
|
||||
{
|
||||
actionbar: {
|
||||
buttons: {
|
||||
|
@ -64,7 +64,7 @@ export function useCrudPermission({ permission }: UseCrudPermissionProps) {
|
|||
|
||||
function merge(userOptions: any) {
|
||||
const permissionOptions = buildCrudPermission();
|
||||
_.merge(permissionOptions, userOptions);
|
||||
LodashMerge(permissionOptions, userOptions);
|
||||
return permissionOptions;
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,9 @@ const util = {
|
|||
const permissionStore = usePermissionStore();
|
||||
const userPermissionList = permissionStore.getPermissions;
|
||||
return userPermissionList.some((permission: any) => {
|
||||
if (permission === "*") {
|
||||
return true;
|
||||
}
|
||||
return need.includes(permission);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import type { ComponentRecordType, GenerateMenuAndRoutesOptions } from "/@/vben/types";
|
||||
|
||||
import { generateAccessible } from "/@/vben/access";
|
||||
import { preferences } from "/@/vben/preferences";
|
||||
|
||||
import { BasicLayout, IFrameView } from "/@/vben/layouts";
|
||||
|
||||
const forbiddenComponent = () => import("#/views/_core/fallback/forbidden.vue");
|
||||
|
||||
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
const pageMap: ComponentRecordType = import.meta.glob("../views/**/*.vue");
|
||||
|
||||
const layoutMap: ComponentRecordType = {
|
||||
BasicLayout,
|
||||
IFrameView
|
||||
} as any;
|
||||
|
||||
return await generateAccessible(preferences.app.accessMode, {
|
||||
...options,
|
||||
// fetchMenuListAsync: async () => {
|
||||
// message.loading({
|
||||
// content: `${$t("common.loadingMenu")}...`,
|
||||
// duration: 1.5
|
||||
// });
|
||||
// return await getAllMenusApi();
|
||||
// },
|
||||
// 可以指定没有权限跳转403页面
|
||||
forbiddenComponent,
|
||||
// 如果 route.meta.menuVisibleWithForbidden = true
|
||||
layoutMap,
|
||||
pageMap
|
||||
});
|
||||
}
|
||||
|
||||
export { generateAccess };
|
|
@ -0,0 +1,112 @@
|
|||
import type { Router } from "vue-router";
|
||||
|
||||
import { DEFAULT_HOME_PATH, LOGIN_PATH } from "/@/vben/constants";
|
||||
import { preferences } from "/@/vben/preferences";
|
||||
import { useAccessStore } from "/@/vben/stores";
|
||||
import { generateMenus, startProgress, stopProgress } from "/@/vben/utils";
|
||||
import { frameworkRoutes } from "/@/router/resolve";
|
||||
import { useSettingStore } from "/@/store/modules/settings";
|
||||
|
||||
/**
|
||||
* 通用守卫配置
|
||||
* @param router
|
||||
*/
|
||||
export function setupCommonGuard(router: Router) {
|
||||
// 记录已经加载的页面
|
||||
const loadedPaths = new Set<string>();
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const settingStore = useSettingStore();
|
||||
await settingStore.initOnce();
|
||||
|
||||
to.meta.loaded = loadedPaths.has(to.path);
|
||||
|
||||
// 页面加载进度条
|
||||
if (!to.meta.loaded && preferences.transition.progress) {
|
||||
startProgress();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
|
||||
|
||||
loadedPaths.add(to.path);
|
||||
|
||||
// 关闭页面加载进度条
|
||||
if (preferences.transition.progress) {
|
||||
stopProgress();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限访问守卫配置
|
||||
* @param router
|
||||
*/
|
||||
function setupAccessGuard(router: Router) {
|
||||
router.beforeEach(async (to, from) => {
|
||||
// 基本路由,这些路由不需要进入权限拦截
|
||||
const needAuth = to.matched.some((r) => {
|
||||
return r.meta?.auth || r.meta?.permission;
|
||||
});
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
if (needAuth) {
|
||||
if (!accessStore.accessToken) {
|
||||
// 没有访问权限,跳转登录页面
|
||||
if (to.fullPath !== LOGIN_PATH) {
|
||||
return {
|
||||
path: LOGIN_PATH,
|
||||
// 如不需要,直接删除 query
|
||||
query: to.fullPath === DEFAULT_HOME_PATH ? {} : { redirect: encodeURIComponent(to.fullPath) },
|
||||
// 携带当前跳转的页面,登录后重新跳转该页面
|
||||
replace: true
|
||||
};
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (!accessStore.isAccessChecked) {
|
||||
const accessibleMenus = await generateMenus(frameworkRoutes[0].children, router);
|
||||
accessStore.setAccessRoutes(frameworkRoutes);
|
||||
accessStore.setAccessMenus(accessibleMenus);
|
||||
accessStore.setIsAccessChecked(true);
|
||||
}
|
||||
|
||||
// 生成菜单和路由
|
||||
// const { accessibleMenus, accessibleRoutes } = await generateAccess({
|
||||
// roles: [],
|
||||
// router,
|
||||
// // 则会在菜单中显示,但是访问会被重定向到403
|
||||
// routes: accessRoutes
|
||||
// });
|
||||
//
|
||||
// // 保存菜单信息和路由信息
|
||||
// accessStore.setAccessMenus(accessibleMenus);
|
||||
// accessStore.setAccessRoutes(accessibleRoutes);
|
||||
|
||||
// const redirectPath = (from.query.redirect ?? (to.path === DEFAULT_HOME_PATH ? DEFAULT_HOME_PATH : to.fullPath)) as string;
|
||||
//
|
||||
// return {
|
||||
// ...router.resolve(decodeURIComponent(redirectPath)),
|
||||
// replace: true
|
||||
// };
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目守卫配置
|
||||
* @param router
|
||||
*/
|
||||
function createRouterGuard(router: Router) {
|
||||
/** 通用 */
|
||||
setupCommonGuard(router);
|
||||
/** 权限访问 */
|
||||
setupAccessGuard(router);
|
||||
}
|
||||
|
||||
export { createRouterGuard };
|
|
@ -1,82 +1,73 @@
|
|||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
// 进度条
|
||||
import NProgress from "nprogress";
|
||||
import "nprogress/nprogress.css";
|
||||
import { usePageStore } from "../store/modules/page";
|
||||
import { site } from "../utils/util.site";
|
||||
import { routes } from "./resolve";
|
||||
import { useResourceStore } from "../store/modules/resource";
|
||||
import { useUserStore } from "../store/modules/user";
|
||||
import { useSettingStore } from "/@/store/modules/settings";
|
||||
import { createRouterGuard } from "/@/router/guard";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
/**
|
||||
* 路由拦截
|
||||
|
||||
*/
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 进度条
|
||||
NProgress.start();
|
||||
const settingStore = useSettingStore();
|
||||
await settingStore.initOnce();
|
||||
const resourceStore = useResourceStore();
|
||||
resourceStore.init();
|
||||
// 修复三级以上路由页面无法缓存的问题
|
||||
if (to.matched && to.matched.length > 2) {
|
||||
to.matched.splice(1, to.matched.length - 2);
|
||||
}
|
||||
// 验证当前路由所有的匹配中是否需要有登录验证的
|
||||
if (
|
||||
to.matched.some((r) => {
|
||||
return r.meta?.auth || r.meta?.permission;
|
||||
})
|
||||
) {
|
||||
const userStore = useUserStore();
|
||||
// 这里暂时将cookie里是否存有token作为验证是否登录的条件
|
||||
// 请根据自身业务需要修改
|
||||
const token = userStore.getToken;
|
||||
if (token) {
|
||||
next();
|
||||
} else {
|
||||
// 没有登录的时候跳转到登录界面
|
||||
// 携带上登陆成功之后需要跳转的页面完整路径
|
||||
resourceStore.clear();
|
||||
next({
|
||||
name: "login",
|
||||
query: {
|
||||
redirect: to.fullPath
|
||||
}
|
||||
});
|
||||
// https://github.com/d2-projects/d2-admin/issues/138
|
||||
NProgress.done();
|
||||
}
|
||||
} else {
|
||||
// 不需要身份校验 直接通过
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach((to: any) => {
|
||||
// 进度条
|
||||
NProgress.done();
|
||||
// 多页控制 打开新的页面
|
||||
const pageStore = usePageStore();
|
||||
// for (const item of to.matched) {
|
||||
// pageStore.keepAlivePush(item.name);
|
||||
// }
|
||||
pageStore.open(to);
|
||||
// 更改标题
|
||||
const settingStore = useSettingStore();
|
||||
site.title(to.meta.title, settingStore.siteInfo.title);
|
||||
|
||||
//修改左侧边栏
|
||||
const matched = to.matched;
|
||||
if (matched.length > 0) {
|
||||
const resourceStore = useResourceStore();
|
||||
resourceStore.setCurrentTopMenuByCurrentRoute(matched);
|
||||
}
|
||||
});
|
||||
createRouterGuard(router);
|
||||
export default router;
|
||||
//
|
||||
// /**
|
||||
// * 路由拦截
|
||||
// */
|
||||
// router.beforeEach(async (to, from, next) => {
|
||||
// // 进度条
|
||||
// NProgress.start();
|
||||
// const settingStore = useSettingStore();
|
||||
// await settingStore.initOnce();
|
||||
// const resourceStore = useResourceStore();
|
||||
// resourceStore.init();
|
||||
// // 修复三级以上路由页面无法缓存的问题
|
||||
// if (to.matched && to.matched.length > 2) {
|
||||
// to.matched.splice(1, to.matched.length - 2);
|
||||
// }
|
||||
// // 验证当前路由所有的匹配中是否需要有登录验证的
|
||||
// if (
|
||||
// to.matched.some((r) => {
|
||||
// return r.meta?.auth || r.meta?.permission;
|
||||
// })
|
||||
// ) {
|
||||
// const userStore = useUserStore();
|
||||
// // 这里暂时将cookie里是否存有token作为验证是否登录的条件
|
||||
// // 请根据自身业务需要修改
|
||||
// const token = userStore.getToken;
|
||||
// if (token) {
|
||||
// next();
|
||||
// } else {
|
||||
// // 没有登录的时候跳转到登录界面
|
||||
// // 携带上登陆成功之后需要跳转的页面完整路径
|
||||
// resourceStore.clear();
|
||||
// next({
|
||||
// name: "login",
|
||||
// query: {
|
||||
// redirect: to.fullPath
|
||||
// }
|
||||
// });
|
||||
// // https://github.com/d2-projects/d2-admin/issues/138
|
||||
// NProgress.done();
|
||||
// }
|
||||
// } else {
|
||||
// // 不需要身份校验 直接通过
|
||||
// next();
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// router.afterEach((to: any) => {
|
||||
// // 进度条
|
||||
// NProgress.done();
|
||||
// // 多页控制 打开新的页面
|
||||
// const pageStore = usePageStore();
|
||||
// // for (const item of to.matched) {
|
||||
// // pageStore.keepAlivePush(item.name);
|
||||
// // }
|
||||
// pageStore.open(to);
|
||||
// // 更改标题
|
||||
// const settingStore = useSettingStore();
|
||||
// site.title(to.meta.title, settingStore.siteInfo.title);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import LayoutPass from "/src/layout/layout-pass.vue";
|
||||
import * as _ from "lodash-es";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { outsideResource } from "./source/outside";
|
||||
import { headerResource } from "./source/header";
|
||||
import { frameworkResource } from "./source/framework";
|
||||
|
@ -19,7 +19,7 @@ function transformOneResource(resource: any, parent: any) {
|
|||
if (meta.isMenu === false) {
|
||||
menu = null;
|
||||
} else {
|
||||
menu = _.cloneDeep(resource);
|
||||
menu = cloneDeep(resource);
|
||||
delete menu.component;
|
||||
if (menu.path?.startsWith("/")) {
|
||||
menu.fullPath = menu.path;
|
||||
|
@ -28,11 +28,11 @@ function transformOneResource(resource: any, parent: any) {
|
|||
}
|
||||
}
|
||||
let route;
|
||||
if (meta.isRoute === false || resource.path == null || resource.path.startsWith("https://") || resource.path.startsWith("http://")) {
|
||||
if (meta.isRoute === false || resource.path == null) {
|
||||
//没有route
|
||||
route = null;
|
||||
} else {
|
||||
route = _.cloneDeep(resource);
|
||||
route = cloneDeep(resource);
|
||||
if (route.component && typeof route.component === "string") {
|
||||
const path = "/src/views" + route.component;
|
||||
route.component = modules[path];
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
import LayoutFramework from "/src/layout/layout-framework.vue";
|
||||
//import { crudResources } from "/@/router/source/modules/crud";
|
||||
import { sysResources } from "/@/router/source/modules/sys";
|
||||
import { certdResources } from "/@/router/source/modules/certd";
|
||||
import LayoutBasic from "/@/layout/layout-basic.vue";
|
||||
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
import { mergeRouteModules } from "/@/vben/utils";
|
||||
const dynamicRouteFiles = import.meta.glob("./modules/**/*.ts", {
|
||||
eager: true
|
||||
});
|
||||
|
||||
/** 动态路由 */
|
||||
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
|
||||
export const frameworkResource = [
|
||||
{
|
||||
title: "框架",
|
||||
name: "framework",
|
||||
name: "root",
|
||||
path: "/",
|
||||
redirect: "/index",
|
||||
component: LayoutFramework,
|
||||
component: LayoutBasic,
|
||||
meta: {
|
||||
icon: "ion:accessibility",
|
||||
auth: true
|
||||
hideInBreadcrumb: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
@ -23,12 +29,13 @@ export const frameworkResource = [
|
|||
meta: {
|
||||
fixedAside: true,
|
||||
showOnHeader: false,
|
||||
icon: "ion:home-outline"
|
||||
icon: "ion:home-outline",
|
||||
auth: true
|
||||
}
|
||||
},
|
||||
//...crudResources,
|
||||
...certdResources,
|
||||
...sysResources
|
||||
// @ts-ignore
|
||||
|
||||
...dynamicRoutes
|
||||
]
|
||||
}
|
||||
];
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { IFrameView } from "/@/vben/layouts";
|
||||
import { useSettingStore } from "/@/store/modules/settings";
|
||||
|
||||
export const aboutResource = [
|
||||
{
|
||||
title: "文档",
|
||||
name: "about",
|
||||
path: "/about",
|
||||
redirect: "/about/doc",
|
||||
meta: {
|
||||
icon: "lucide:copyright",
|
||||
order: 9999,
|
||||
show: () => {
|
||||
const settingStore = useSettingStore();
|
||||
return !settingStore.isComm;
|
||||
}
|
||||
},
|
||||
children: [
|
||||
{
|
||||
title: "文档",
|
||||
name: "document",
|
||||
path: "/about/doc",
|
||||
component: IFrameView,
|
||||
meta: {
|
||||
icon: "lucide:book-open-text",
|
||||
link: "https://certd.docmirror.cn",
|
||||
title: "文档"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Github",
|
||||
path: "/about/github",
|
||||
component: IFrameView,
|
||||
meta: {
|
||||
icon: "mdi:github",
|
||||
link: "https://github.com/certd/certd",
|
||||
title: "Github"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Gitee",
|
||||
path: "/about/gitee",
|
||||
component: IFrameView,
|
||||
meta: {
|
||||
icon: "ion:logo-octocat",
|
||||
link: "https://gitee.com/certd/certd",
|
||||
title: "Gite"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default aboutResource;
|
|
@ -1,4 +1,5 @@
|
|||
import { useSettingStore } from "/@/store/modules/settings";
|
||||
import aboutResource from "/@/router/source/modules/about";
|
||||
|
||||
export const certdResources = [
|
||||
{
|
||||
|
@ -8,7 +9,8 @@ export const certdResources = [
|
|||
redirect: "/certd/pipeline",
|
||||
meta: {
|
||||
icon: "ion:key-outline",
|
||||
auth: true
|
||||
auth: true,
|
||||
order: 0
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
@ -220,3 +222,5 @@ export const certdResources = [
|
|||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default certdResources;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import LayoutPass from "/@/layout/layout-pass.vue";
|
||||
import { useSettingStore } from "/@/store/modules/settings";
|
||||
import aboutResource from "/@/router/source/modules/about";
|
||||
|
||||
export const sysResources = [
|
||||
{
|
||||
|
@ -7,10 +8,10 @@ export const sysResources = [
|
|||
name: "SysRoot",
|
||||
path: "/sys",
|
||||
redirect: "/sys/settings",
|
||||
component: LayoutPass,
|
||||
meta: {
|
||||
icon: "ion:settings-outline",
|
||||
permission: "sys:settings:view"
|
||||
permission: "sys:settings:view",
|
||||
order: 10
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
@ -231,3 +232,5 @@ export const sysResources = [
|
|||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default sysResources;
|
||||
|
|
|
@ -1,437 +0,0 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { cloneDeep, get, uniq } from "lodash-es";
|
||||
import router from "/src/router";
|
||||
import { frameworkRoutes } from "/src/router/resolve";
|
||||
// @ts-ignore
|
||||
import { LocalStorage } from "/src/utils/util.storage";
|
||||
import { useUserStore } from "/src/store/modules/user";
|
||||
const OPENED_CACHE_KEY = "TABS_OPENED";
|
||||
|
||||
interface PageState {
|
||||
// 可以在多页 tab 模式下显示的页面
|
||||
pool: Array<any>;
|
||||
// 当前显示的多页面列表
|
||||
opened: Array<any>;
|
||||
// 已经加载多标签页数据 https://github.com/d2-projects/d2-admin/issues/201
|
||||
openedLoaded: boolean;
|
||||
// 当前页面
|
||||
current: string;
|
||||
// 需要缓存的页面 name
|
||||
keepAlive: Array<any>;
|
||||
inited: boolean;
|
||||
}
|
||||
// 判定是否需要缓存
|
||||
const isKeepAlive = (data: any) => get(data, "meta.cache", false);
|
||||
|
||||
export const usePageStore = defineStore({
|
||||
id: "app.page",
|
||||
state: (): PageState => ({
|
||||
// 可以在多页 tab 模式下显示的页面
|
||||
pool: [],
|
||||
// 当前显示的多页面列表
|
||||
opened: [
|
||||
{
|
||||
name: "index",
|
||||
fullPath: "/index",
|
||||
meta: {
|
||||
title: "首页",
|
||||
auth: false
|
||||
}
|
||||
}
|
||||
],
|
||||
// 已经加载多标签页数据 https://github.com/d2-projects/d2-admin/issues/201
|
||||
openedLoaded: false,
|
||||
// 当前页面
|
||||
current: "",
|
||||
// 需要缓存的页面 name
|
||||
keepAlive: [],
|
||||
inited: false
|
||||
}),
|
||||
getters: {
|
||||
// @ts-ignore
|
||||
getOpened(): any {
|
||||
// @ts-ignore
|
||||
return this.opened;
|
||||
},
|
||||
getCurrent(): string {
|
||||
return this.current;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* @description 确认已经加载多标签页数据 https://github.com/d2-projects/d2-admin/issues/201
|
||||
* @param {Object} context
|
||||
*/
|
||||
async isLoaded() {
|
||||
if (this.openedLoaded) {
|
||||
return true;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setInterval(() => {
|
||||
if (this.openedLoaded) {
|
||||
resolve(clearInterval(timer));
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 从持久化数据载入标签页列表
|
||||
* @param {Object} context
|
||||
*/
|
||||
async openedLoad() {
|
||||
// store 赋值
|
||||
const value = LocalStorage.get(this.getStorageKey());
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
// 在处理函数中进行数据优化 过滤掉现在已经失效的页签或者已经改变了信息的页签
|
||||
// 以 fullPath 字段为准
|
||||
// 如果页面过多的话可能需要优化算法
|
||||
// valid 有效列表 1, 1, 0, 1 => 有效, 有效, 失效, 有效
|
||||
const valid: Array<number> = [];
|
||||
// 处理数据
|
||||
this.opened = value
|
||||
.map((opened: any) => {
|
||||
// 忽略首页
|
||||
if (opened.fullPath === "/index") {
|
||||
valid.push(1);
|
||||
return opened;
|
||||
}
|
||||
// 尝试在所有的支持多标签页的页面里找到 name 匹配的页面
|
||||
const find = this.pool.find((item) => item.name === opened.name);
|
||||
// 记录有效或无效信息
|
||||
valid.push(find ? 1 : 0);
|
||||
// 返回合并后的数据 新的覆盖旧的
|
||||
// 新的数据中一般不会携带 params 和 query, 所以旧的参数会留存
|
||||
return Object.assign({}, opened, find);
|
||||
})
|
||||
.filter((opened: any, index: any) => valid[index] === 1);
|
||||
// 标记已经加载多标签页数据 https://github.com/d2-projects/d2-admin/issues/201
|
||||
this.openedLoaded = true;
|
||||
// 根据 opened 数据生成缓存设置
|
||||
this.keepAliveRefresh();
|
||||
},
|
||||
|
||||
getStorageKey() {
|
||||
const userStore = useUserStore();
|
||||
const userId = userStore.getUserInfo?.id ?? "anonymous";
|
||||
return OPENED_CACHE_KEY + ":" + userId;
|
||||
},
|
||||
/**
|
||||
* 将 opened 属性赋值并持久化 在这之前请先确保已经更新了 state.opened
|
||||
* @param {Object} context
|
||||
*/
|
||||
async opened2db() {
|
||||
// 设置数据
|
||||
|
||||
LocalStorage.set(this.getStorageKey(), this.opened);
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 更新页面列表上的某一项
|
||||
* @param {Object} context
|
||||
* @param {Object} payload { index, params, query, fullPath } 路由信息
|
||||
*/
|
||||
async openedUpdate({ index, params, query, fullPath }: any) {
|
||||
// 更新页面列表某一项
|
||||
const page = this.opened[index];
|
||||
page.params = params || page.params;
|
||||
page.query = query || page.query;
|
||||
page.fullPath = fullPath || page.fullPath;
|
||||
this.opened.splice(index, 1, page);
|
||||
// 持久化
|
||||
await this.opened2db();
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 重排页面列表上的某一项
|
||||
* @param {Object} context
|
||||
* @param {Object} payload { oldIndex, newIndex } 位置信息
|
||||
*/
|
||||
async openedSort({ oldIndex, newIndex }: any) {
|
||||
// 重排页面列表某一项
|
||||
const page = this.opened[oldIndex];
|
||||
this.opened.splice(oldIndex, 1);
|
||||
this.opened.splice(newIndex, 0, page);
|
||||
// 持久化
|
||||
await this.opened2db();
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 新增一个 tag (打开一个页面)
|
||||
* @param {Object} context
|
||||
* @param {Object} payload new tag info
|
||||
*/
|
||||
async add({ tag, params, query, fullPath }: any) {
|
||||
// 设置新的 tag 在新打开一个以前没打开过的页面时使用
|
||||
const newTag = tag;
|
||||
newTag.params = params || newTag.params;
|
||||
newTag.query = query || newTag.query;
|
||||
newTag.fullPath = fullPath || newTag.fullPath;
|
||||
// 添加进当前显示的页面数组
|
||||
this.opened.push(newTag);
|
||||
// 如果这个页面需要缓存 将其添加到缓存设置
|
||||
if (isKeepAlive(newTag)) {
|
||||
this.keepAlivePush(tag.name);
|
||||
}
|
||||
// 持久化
|
||||
await this.opened2db();
|
||||
},
|
||||
/**
|
||||
* @class current
|
||||
* @description 打开一个新的页面
|
||||
* @param {Object} context
|
||||
* @param {Object} payload 从路由钩子的 to 对象上获取 { name, params, query, fullPath, meta } 路由信息
|
||||
*/
|
||||
async open({ name, params, query, fullPath, meta }: any) {
|
||||
// 已经打开的页面
|
||||
const opened = this.opened;
|
||||
// 判断此页面是否已经打开 并且记录位置
|
||||
let pageOpendIndex = 0;
|
||||
const pageOpend = opened.find((page, index) => {
|
||||
const same = page.fullPath === fullPath;
|
||||
pageOpendIndex = same ? index : pageOpendIndex;
|
||||
return same;
|
||||
});
|
||||
if (pageOpend) {
|
||||
// 页面以前打开过
|
||||
await this.openedUpdate({
|
||||
index: pageOpendIndex,
|
||||
params,
|
||||
query,
|
||||
fullPath
|
||||
});
|
||||
} else {
|
||||
// 页面以前没有打开过
|
||||
const page = this.pool.find((t) => t.name === name);
|
||||
// 如果这里没有找到 page 代表这个路由虽然在框架内 但是不参与标签页显示
|
||||
if (page) {
|
||||
this.add({
|
||||
tag: Object.assign({}, page),
|
||||
params,
|
||||
query,
|
||||
fullPath
|
||||
});
|
||||
}
|
||||
}
|
||||
// 如果这个页面需要缓存 将其添加到缓存设置
|
||||
if (isKeepAlive({ meta })) {
|
||||
this.keepAlivePush(name);
|
||||
}
|
||||
// 设置当前的页面
|
||||
this.currentSet(fullPath);
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 关闭一个 tag (关闭一个页面)
|
||||
* @param {Object} context
|
||||
* @param {Object} payload { tagName: 要关闭的标签名字 }
|
||||
*/
|
||||
async close({ tagName }: any) {
|
||||
// 预定下个新页面
|
||||
let newPage = {};
|
||||
const isCurrent = this.current === tagName;
|
||||
// 如果关闭的页面就是当前显示的页面
|
||||
if (isCurrent) {
|
||||
// 去找一个新的页面
|
||||
const len = this.opened.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (this.opened[i].fullPath === tagName) {
|
||||
newPage = i < len - 1 ? this.opened[i + 1] : this.opened[i - 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 找到这个页面在已经打开的数据里是第几个
|
||||
const index = this.opened.findIndex((page) => page.fullPath === tagName);
|
||||
if (index >= 0) {
|
||||
// 如果这个页面是缓存的页面 将其在缓存设置中删除
|
||||
this.keepAliveRemove(this.opened[index].name);
|
||||
// 更新数据 删除关闭的页面
|
||||
this.opened.splice(index, 1);
|
||||
}
|
||||
// 持久化
|
||||
await this.opened2db();
|
||||
// 决定最后停留的页面
|
||||
if (isCurrent) {
|
||||
// @ts-ignore
|
||||
const { name = "index", params = {}, query = {} } = newPage;
|
||||
const routerObj = { name, params, query };
|
||||
await router.push(routerObj);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 关闭当前标签左边的标签
|
||||
* @param opts
|
||||
*/
|
||||
async closeLeft(opts = {}) {
|
||||
await this.closeByCondition({
|
||||
condition({ i, currentIndex }: any) {
|
||||
return i >= currentIndex;
|
||||
},
|
||||
...opts
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 关闭当前标签右边的标签
|
||||
* @param opts
|
||||
*/
|
||||
async closeRight(opts = {}) {
|
||||
await this.closeByCondition({
|
||||
condition({ i, currentIndex }: any) {
|
||||
return currentIndex >= i;
|
||||
},
|
||||
...opts
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 关闭当前标签右边的标签
|
||||
* @param opts
|
||||
*/
|
||||
async closeByCondition(opts = {}) {
|
||||
// @ts-ignore
|
||||
const { pageSelect, condition } = opts;
|
||||
const pageAim = pageSelect || this.current;
|
||||
let currentIndex = 0;
|
||||
this.opened.forEach((page, index) => {
|
||||
if (page.fullPath === pageAim) currentIndex = index;
|
||||
});
|
||||
// 删除打开的页面 并在缓存设置中删除
|
||||
for (let i = this.opened.length - 1; i >= 0; i--) {
|
||||
if (this.opened[i].name === "index" || condition({ i, currentIndex })) {
|
||||
continue;
|
||||
}
|
||||
this.keepAliveRemove(this.opened[i].name);
|
||||
this.opened.splice(i, 1);
|
||||
}
|
||||
// 持久化
|
||||
await this.opened2db();
|
||||
// 设置当前的页面
|
||||
this.current = pageAim;
|
||||
// @ts-ignore
|
||||
if (router.currentRoute.fullPath !== pageAim) await router.push(pageAim);
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 关闭当前激活之外的 tag
|
||||
* @param opts
|
||||
*/
|
||||
async closeOther(opts = {}) {
|
||||
await this.closeByCondition({
|
||||
condition({ i, currentIndex }: any) {
|
||||
return currentIndex === i;
|
||||
},
|
||||
...opts
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @class opened
|
||||
* @description 关闭所有 tag
|
||||
* @param {Object} context
|
||||
*/
|
||||
async closeAll() {
|
||||
// 删除打开的页面 并在缓存设置中删除
|
||||
for (let i = this.opened.length - 1; i >= 0; i--) {
|
||||
if (this.opened[i].name === "index") {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.keepAliveRemove(this.opened[i].name);
|
||||
this.opened.splice(i, 1);
|
||||
}
|
||||
// 持久化
|
||||
await this.opened2db();
|
||||
// 关闭所有的标签页后需要判断一次现在是不是在首页
|
||||
// @ts-ignore
|
||||
if (router.currentRoute.name !== "index") {
|
||||
await router.push({ name: "index" });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @class keepAlive
|
||||
* @description 从已经打开的页面记录中更新需要缓存的页面记录
|
||||
* @param {Object} state state
|
||||
*/
|
||||
keepAliveRefresh() {
|
||||
this.keepAlive = this.opened.filter((item) => isKeepAlive(item)).map((e) => e.name);
|
||||
console.log("keepalive", this.keepAlive);
|
||||
},
|
||||
/**
|
||||
* @description 删除一个页面的缓存设置
|
||||
* @param {Object} state state
|
||||
* @param {String} name name
|
||||
*/
|
||||
keepAliveRemove(name: string) {
|
||||
const list = cloneDeep(this.keepAlive);
|
||||
const index = list.findIndex((item) => item === name);
|
||||
if (index !== -1) {
|
||||
list.splice(index, 1);
|
||||
this.keepAlive = list;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @description 增加一个页面的缓存设置
|
||||
* @param {Object} state state
|
||||
* @param {String} name name
|
||||
*/
|
||||
keepAlivePush(name: string) {
|
||||
const keep = cloneDeep(this.keepAlive);
|
||||
keep.push(name);
|
||||
this.keepAlive = uniq(keep);
|
||||
},
|
||||
/**
|
||||
* @description 清空页面缓存设置
|
||||
* @param {Object} state state
|
||||
*/
|
||||
keepAliveClean() {
|
||||
this.keepAlive = [];
|
||||
},
|
||||
/**
|
||||
* @class current
|
||||
* @description 设置当前激活的页面 fullPath
|
||||
* @param {Object} state state
|
||||
* @param {String} fullPath new fullPath
|
||||
*/
|
||||
currentSet(fullPath: string) {
|
||||
this.current = fullPath;
|
||||
},
|
||||
/**
|
||||
* @class pool
|
||||
* @description 保存 pool (候选池)
|
||||
* @param {Object} state state
|
||||
* @param {Array} routes routes
|
||||
*/
|
||||
async init(routes?: any) {
|
||||
if (this.inited) {
|
||||
return;
|
||||
}
|
||||
this.inited = true;
|
||||
if (routes == null) {
|
||||
//不能用全部的routes,只能是framework内的
|
||||
routes = frameworkRoutes;
|
||||
}
|
||||
|
||||
const pool: any = [];
|
||||
const push = function (routes: any) {
|
||||
routes.forEach((route: any) => {
|
||||
if (route.children && route.children.length > 0) {
|
||||
push(route.children);
|
||||
} else {
|
||||
if (!route.hidden) {
|
||||
const { meta, name, path } = route;
|
||||
// @ts-ignore
|
||||
pool.push({ meta, name, path });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
push(routes);
|
||||
this.pool = pool;
|
||||
await this.openedLoad();
|
||||
}
|
||||
}
|
||||
});
|
|
@ -1,148 +0,0 @@
|
|||
import { defineStore } from "pinia";
|
||||
// @ts-ignore
|
||||
import { frameworkMenus, headerMenus, filterMenus, findMenus } from "/src/router/resolve";
|
||||
import * as _ from "lodash-es";
|
||||
import { mitter } from "/src/utils/util.mitt";
|
||||
//监听注销事件
|
||||
mitter.on("app.logout", () => {
|
||||
const resourceStore = useResourceStore();
|
||||
resourceStore.clear();
|
||||
});
|
||||
//
|
||||
// mitter.on("app.login", () => {
|
||||
// const resourceStore = useResourceStore();
|
||||
// resourceStore.clear();
|
||||
// resourceStore.init();
|
||||
// });
|
||||
|
||||
interface ResourceState {
|
||||
topMenus: Array<any>;
|
||||
authedTopMenus: Array<any>;
|
||||
headerMenus: Array<any>;
|
||||
asideMenus: Array<any>;
|
||||
fixedAsideMenus: Array<any>;
|
||||
inited: boolean;
|
||||
currentTopMenu?: string;
|
||||
currentAsidePath?: string;
|
||||
}
|
||||
|
||||
export const useResourceStore = defineStore({
|
||||
id: "app.resource",
|
||||
//@ts-ignore
|
||||
state: (): ResourceState => ({
|
||||
// user info
|
||||
topMenus: [],
|
||||
authedTopMenus: [],
|
||||
headerMenus: [],
|
||||
fixedAsideMenus: [],
|
||||
inited: false,
|
||||
currentTopMenu: undefined,
|
||||
currentAsidePath: undefined
|
||||
}),
|
||||
getters: {
|
||||
getAsideMenus() {
|
||||
let topMenu = this.currentTopMenu;
|
||||
if (!topMenu && this.authedTopMenus.length > 0) {
|
||||
topMenu = this.authedTopMenus[0];
|
||||
}
|
||||
let asideMenus = topMenu?.children || [];
|
||||
asideMenus = [...this.fixedAsideMenus, ...asideMenus];
|
||||
return asideMenus;
|
||||
},
|
||||
getHeaderMenus() {
|
||||
return this.headerMenus;
|
||||
},
|
||||
getFrameworkMenus() {
|
||||
return this.authedTopMenus;
|
||||
// const menus = _.cloneDeep(this.authedTopMenus);
|
||||
// for (const menu of menus) {
|
||||
// delete menu.children;
|
||||
// }
|
||||
// return menus;
|
||||
}
|
||||
} as any,
|
||||
actions: {
|
||||
clear() {
|
||||
this.inited = false;
|
||||
this.currentTopMenu = undefined;
|
||||
},
|
||||
/**
|
||||
* 初始化资源
|
||||
*/
|
||||
init() {
|
||||
if (this.inited) {
|
||||
return;
|
||||
}
|
||||
this.inited = true;
|
||||
|
||||
const allMenus = _.cloneDeep(frameworkMenus[0].children);
|
||||
this.topMenus = filterMenus(allMenus, (item: any) => {
|
||||
return item?.meta?.showOnHeader !== false;
|
||||
});
|
||||
|
||||
this.fixedAsideMenus = findMenus(allMenus, (item: any) => {
|
||||
return item?.meta?.fixedAside === true;
|
||||
});
|
||||
this.headerMenus = headerMenus;
|
||||
},
|
||||
setCurrentTopMenu(topMenu?: any) {
|
||||
if (this.topMenus.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (topMenu == null) {
|
||||
topMenu = this.topMenus[0];
|
||||
}
|
||||
this.currentTopMenu = topMenu;
|
||||
},
|
||||
setCurrentTopMenuByCurrentRoute(matched: any) {
|
||||
const menuHeader = this.authedTopMenus;
|
||||
if (matched?.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
function findFromTree(tree: any, find: any) {
|
||||
tree = tree || [];
|
||||
const results: Array<any> = [];
|
||||
for (const item of tree) {
|
||||
if (find(item)) {
|
||||
results.push(item);
|
||||
return results;
|
||||
}
|
||||
if (item.children && item.children.length > 0) {
|
||||
const found: any = findFromTree(item.children, find);
|
||||
if (found) {
|
||||
results.push(item);
|
||||
return results.concat(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const matchedPath = matched[1].path;
|
||||
const _side = findFromTree(menuHeader, (menu: any) => menu.path === matchedPath);
|
||||
if (_side?.length > 0) {
|
||||
if (this.currentAsidePath === _side[0]) {
|
||||
return;
|
||||
}
|
||||
this.currentAsidePath = _side[0];
|
||||
this.setCurrentTopMenu(_side[0]);
|
||||
}
|
||||
},
|
||||
filterByPermission(permissions: any) {
|
||||
this.authedTopMenus = this.filterChildrenByPermission(this.topMenus, permissions);
|
||||
},
|
||||
filterChildrenByPermission(list: any, permissions: any) {
|
||||
const menus = list.filter((item: any) => {
|
||||
if (item?.meta?.permission) {
|
||||
return permissions.includes(item.meta.permission);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
for (const menu of menus) {
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
menu.children = this.filterChildrenByPermission(menu.children, permissions);
|
||||
}
|
||||
}
|
||||
return menus;
|
||||
}
|
||||
}
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { Modal, notification, theme } from "ant-design-vue";
|
||||
import { Modal, notification } from "ant-design-vue";
|
||||
import * as _ from "lodash-es";
|
||||
// @ts-ignore
|
||||
import { LocalStorage } from "/src/utils/util.storage";
|
||||
|
@ -9,20 +9,9 @@ import { HeaderMenus, PlusInfo, SiteEnv, SiteInfo, SuiteSetting, SysInstallInfo,
|
|||
import { useUserStore } from "/@/store/modules/user";
|
||||
import { mitter } from "/@/utils/util.mitt";
|
||||
import { env } from "/@/utils/util.env";
|
||||
import { preferences } from "/@/vben/preferences";
|
||||
|
||||
export type ThemeToken = {
|
||||
token: {
|
||||
colorPrimary?: string;
|
||||
};
|
||||
algorithm: any;
|
||||
};
|
||||
export type ThemeConfig = {
|
||||
colorPrimary: string;
|
||||
mode: string;
|
||||
};
|
||||
export interface SettingState {
|
||||
themeConfig?: ThemeConfig;
|
||||
themeToken: ThemeToken;
|
||||
sysPublic?: SysPublicSetting;
|
||||
installInfo?: {
|
||||
siteId: string;
|
||||
|
@ -40,11 +29,6 @@ export interface SettingState {
|
|||
suiteSetting?: SuiteSetting;
|
||||
}
|
||||
|
||||
const defaultThemeConfig = {
|
||||
colorPrimary: "#1890ff",
|
||||
mode: "light"
|
||||
};
|
||||
const SETTING_THEME_KEY = "SETTING_THEME";
|
||||
const defaultSiteInfo: SiteInfo = {
|
||||
title: env.TITLE || "Certd",
|
||||
slogan: env.SLOGAN || "让你的证书永不过期",
|
||||
|
@ -56,11 +40,6 @@ const defaultSiteInfo: SiteInfo = {
|
|||
export const useSettingStore = defineStore({
|
||||
id: "app.setting",
|
||||
state: (): SettingState => ({
|
||||
themeConfig: null,
|
||||
themeToken: {
|
||||
token: {},
|
||||
algorithm: theme.defaultAlgorithm
|
||||
},
|
||||
plusInfo: {
|
||||
isPlus: false,
|
||||
vipType: "free",
|
||||
|
@ -93,9 +72,6 @@ export const useSettingStore = defineStore({
|
|||
inited: false
|
||||
}),
|
||||
getters: {
|
||||
getThemeConfig(): any {
|
||||
return this.themeConfig || _.merge({}, defaultThemeConfig, LocalStorage.get(SETTING_THEME_KEY) || {});
|
||||
},
|
||||
getSysPublic(): SysPublicSetting {
|
||||
return this.sysPublic;
|
||||
},
|
||||
|
@ -162,6 +138,10 @@ export const useSettingStore = defineStore({
|
|||
}
|
||||
}
|
||||
this.siteInfo = _.merge({}, defaultSiteInfo, siteInfo);
|
||||
|
||||
if (this.siteInfo.logo) {
|
||||
preferences.logo.source = this.siteInfo.logo;
|
||||
}
|
||||
},
|
||||
async checkUrlBound() {
|
||||
const userStore = useUserStore();
|
||||
|
@ -207,44 +187,7 @@ export const useSettingStore = defineStore({
|
|||
}
|
||||
}
|
||||
},
|
||||
persistThemeConfig() {
|
||||
LocalStorage.set(SETTING_THEME_KEY, this.getThemeConfig);
|
||||
},
|
||||
async setThemeConfig(themeConfig?: ThemeConfig) {
|
||||
this.themeConfig = _.merge({}, this.themeConfig, themeConfig);
|
||||
|
||||
this.persistThemeConfig();
|
||||
this.setPrimaryColor(this.themeConfig.colorPrimary);
|
||||
this.setDarkMode(this.themeConfig.mode);
|
||||
},
|
||||
setPrimaryColor(color: any) {
|
||||
this.themeConfig.colorPrimary = color;
|
||||
_.set(this.themeToken, "token.colorPrimary", color);
|
||||
this.persistThemeConfig();
|
||||
},
|
||||
setDarkMode(mode: string) {
|
||||
this.themeConfig.mode = mode;
|
||||
if (mode === "dark") {
|
||||
this.themeToken.algorithm = theme.darkAlgorithm;
|
||||
// const defaultSeed = theme.defaultSeed;
|
||||
// const mapToken = theme.darkAlgorithm(defaultSeed);
|
||||
// less.modifyVars(mapToken);
|
||||
// less.modifyVars({
|
||||
// "@colorPrimaryBg": "#111a2c",
|
||||
// colorPrimaryBg: "#111a2c"
|
||||
// });
|
||||
// less.refreshStyles();
|
||||
} else {
|
||||
this.themeToken.algorithm = theme.defaultAlgorithm;
|
||||
|
||||
// const defaultSeed = theme.defaultSeed;
|
||||
// const mapToken = theme.defaultAlgorithm(defaultSeed);
|
||||
// less.modifyVars(mapToken);
|
||||
}
|
||||
this.persistThemeConfig();
|
||||
},
|
||||
async init() {
|
||||
await this.setThemeConfig(this.getThemeConfig);
|
||||
await this.loadSysSettings();
|
||||
},
|
||||
async initOnce() {
|
||||
|
|
|
@ -11,6 +11,7 @@ import { message, Modal, notification } from "ant-design-vue";
|
|||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { mitter } from "/src/utils/util.mitt";
|
||||
import { resetAllStores, useAccessStore } from "/@/vben/stores";
|
||||
|
||||
interface UserState {
|
||||
userInfo: Nullable<UserInfoRes>;
|
||||
|
@ -39,8 +40,10 @@ export const useUserStore = defineStore({
|
|||
}
|
||||
},
|
||||
actions: {
|
||||
setToken(info: string, expire: number) {
|
||||
this.token = info;
|
||||
setToken(token: string, expire: number) {
|
||||
this.token = token;
|
||||
const accessStore = useAccessStore();
|
||||
accessStore.setAccessToken(token);
|
||||
LocalStorage.set(TOKEN_KEY, this.token, expire);
|
||||
},
|
||||
setUserInfo(info: UserInfoRes) {
|
||||
|
@ -92,11 +95,10 @@ export const useUserStore = defineStore({
|
|||
},
|
||||
|
||||
async onLoginSuccess(loginData: any) {
|
||||
await this.getUserInfoAction();
|
||||
const userInfo = await this.getUserInfoAction();
|
||||
mitter.emit("app.login", { userInfo, token: loginData });
|
||||
// await this.getUserInfoAction();
|
||||
// const userInfo = await this.getUserInfoAction();
|
||||
mitter.emit("app.login", { token: loginData });
|
||||
await router.replace("/");
|
||||
return userInfo;
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -104,6 +106,7 @@ export const useUserStore = defineStore({
|
|||
*/
|
||||
logout(goLogin = true) {
|
||||
this.resetState();
|
||||
resetAllStores();
|
||||
goLogin && router.push("/login");
|
||||
mitter.emit("app.logout");
|
||||
},
|
||||
|
|
|
@ -43,10 +43,20 @@
|
|||
}
|
||||
|
||||
|
||||
.ant-modal {
|
||||
max-width: calc(100% - 32px) !important ;
|
||||
//适配手机端
|
||||
.ant-tour{
|
||||
max-width: 90vw
|
||||
}
|
||||
|
||||
.fs-search .ant-row{
|
||||
flex-flow: row wrap !important;
|
||||
.fs-page{
|
||||
.fs-page-header{
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
.fs-crud-table{
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
}
|
||||
|
||||
footer{
|
||||
background-color: hsl(var(--card)) !important;
|
||||
}
|
|
@ -77,7 +77,6 @@ h1, h2, h3, h4, h5, h6 {
|
|||
|
||||
.flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.flex-inline {
|
||||
display: inline-flex;
|
||||
|
@ -107,86 +106,86 @@ h1, h2, h3, h4, h5, h6 {
|
|||
|
||||
}
|
||||
.m-0{
|
||||
margin:0
|
||||
margin:0 !important;
|
||||
}
|
||||
.m-2{
|
||||
margin:2px
|
||||
margin:2px !important;
|
||||
}
|
||||
.m-3{
|
||||
margin:3px
|
||||
margin:3px !important;
|
||||
}
|
||||
.m-5{
|
||||
margin:5px
|
||||
margin:5px !important;
|
||||
}
|
||||
.m-10 {
|
||||
margin: 10px;
|
||||
margin: 10px !important;;
|
||||
}
|
||||
|
||||
.m-20{
|
||||
margin:20px
|
||||
margin:20px !important;
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 2px !important;;
|
||||
}
|
||||
|
||||
.mb-5 {
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 5px !important;;
|
||||
}
|
||||
.ml-5 {
|
||||
margin-left: 5px;
|
||||
margin-left: 5px !important;;
|
||||
}
|
||||
|
||||
.ml-10 {
|
||||
margin-left: 10px;
|
||||
margin-left: 10px !important;;
|
||||
}
|
||||
|
||||
.ml-20 {
|
||||
margin-left: 20px;
|
||||
margin-left: 20px !important;;
|
||||
}
|
||||
|
||||
.ml-15 {
|
||||
margin-left: 15px;
|
||||
margin-left: 15px !important;;
|
||||
}
|
||||
|
||||
.mr-5 {
|
||||
margin-right: 5px;
|
||||
margin-right: 5px !important;;
|
||||
}
|
||||
|
||||
.mr-10 {
|
||||
margin-right: 10px;
|
||||
margin-right: 10px !important;;
|
||||
}
|
||||
|
||||
.mr-20 {
|
||||
margin-right: 20px;
|
||||
margin-right: 20px !important;;
|
||||
}
|
||||
|
||||
.mr-15 {
|
||||
margin-right: 15px;
|
||||
margin-right: 15px !important;;
|
||||
}
|
||||
|
||||
.mt-5 {
|
||||
margin-top: 5px;
|
||||
margin-top: 5px !important;;
|
||||
}
|
||||
|
||||
.mt-10 {
|
||||
margin-top: 10px;
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.mb-10 {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 10px !important;;
|
||||
}
|
||||
|
||||
|
||||
.p-5 {
|
||||
padding: 5px;
|
||||
padding: 5px !important;;
|
||||
}
|
||||
|
||||
.p-10 {
|
||||
padding: 10px;
|
||||
padding: 10px !important;;
|
||||
}
|
||||
|
||||
.p-20 {
|
||||
padding: 20px;
|
||||
padding: 20px !important;;
|
||||
}
|
||||
.ellipsis {
|
||||
white-space: nowrap;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import * as _ from "lodash-es";
|
||||
import { isArray } from "lodash-es";
|
||||
export default {
|
||||
arrayToMap(array: any) {
|
||||
if (!array) {
|
||||
return {};
|
||||
}
|
||||
if (!_.isArray(array)) {
|
||||
if (!isArray(array)) {
|
||||
return array;
|
||||
}
|
||||
const map: any = {};
|
||||
|
@ -19,7 +19,7 @@ export default {
|
|||
if (!map) {
|
||||
return [];
|
||||
}
|
||||
if (_.isArray(map)) {
|
||||
if (isArray(map)) {
|
||||
return map;
|
||||
}
|
||||
const array: any = [];
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
// @ts-ignore
|
||||
import * as _ from "lodash-es";
|
||||
import {forEach} from "lodash-es";
|
||||
export function getEnvValue(key: string) {
|
||||
// @ts-ignore
|
||||
return import.meta.env["VITE_APP_" + key];
|
||||
}
|
||||
|
||||
export class EnvConfig {
|
||||
MODE: string = import.meta.env.MODE;
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
<!--
|
||||
Access control component for fine-grained access control.
|
||||
TODO: 可以扩展更完善的功能:
|
||||
1. 支持多个权限码,只要有一个权限码满足即可 或者 多个权限码全部满足
|
||||
2. 支持多个角色,只要有一个角色满足即可 或者 多个角色全部满足
|
||||
3. 支持自定义权限码和角色的判断逻辑
|
||||
-->
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
import { useAccess } from "./use-access";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Specified codes is visible
|
||||
* @default []
|
||||
*/
|
||||
codes?: string[];
|
||||
|
||||
/**
|
||||
* 通过什么方式来控制组件,如果是 role,则传入角色,如果是 code,则传入权限码
|
||||
* @default 'role'
|
||||
*/
|
||||
type?: "code" | "role";
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "AccessControl"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
codes: () => [],
|
||||
type: "role"
|
||||
});
|
||||
|
||||
const { hasAccessByCodes, hasAccessByRoles } = useAccess();
|
||||
|
||||
const hasAuth = computed(() => {
|
||||
const { codes, type } = props;
|
||||
return type === "role" ? hasAccessByRoles(codes) : hasAccessByCodes(codes);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot v-if="!codes"></slot>
|
||||
<slot v-else-if="hasAuth"></slot>
|
||||
</template>
|
|
@ -0,0 +1,84 @@
|
|||
import type { AccessModeType, GenerateMenuAndRoutesOptions, RouteRecordRaw } from "/@/vben/types";
|
||||
|
||||
import { cloneDeep, generateMenus, generateRoutesByBackend, generateRoutesByFrontend, mapTree } from "/@/vben/utils";
|
||||
|
||||
async function generateAccessible(mode: AccessModeType, options: GenerateMenuAndRoutesOptions) {
|
||||
const { router } = options;
|
||||
|
||||
options.routes = cloneDeep(options.routes);
|
||||
// 生成路由
|
||||
const accessibleRoutes = await generateRoutes(mode, options);
|
||||
|
||||
const root = router.getRoutes().find((item: any) => item.path === "/");
|
||||
|
||||
// 动态添加到router实例内
|
||||
accessibleRoutes.forEach((route) => {
|
||||
if (root && !route.meta?.noBasicLayout) {
|
||||
// 为了兼容之前的版本用法,如果包含子路由,则将component移除,以免出现多层BasicLayout
|
||||
// 如果你的项目已经跟进了本次修改,移除了所有自定义菜单首级的BasicLayout,可以将这段if代码删除
|
||||
if (route.children && route.children.length > 0) {
|
||||
delete route.component;
|
||||
}
|
||||
root.children?.push(route);
|
||||
} else {
|
||||
router.addRoute(route);
|
||||
}
|
||||
});
|
||||
|
||||
if (root) {
|
||||
if (root.name) {
|
||||
router.removeRoute(root.name);
|
||||
}
|
||||
router.addRoute(root);
|
||||
}
|
||||
|
||||
// 生成菜单
|
||||
const accessibleMenus = await generateMenus(accessibleRoutes, options.router);
|
||||
|
||||
return { accessibleMenus, accessibleRoutes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate routes
|
||||
* @param mode
|
||||
* @param options
|
||||
*/
|
||||
async function generateRoutes(mode: AccessModeType, options: GenerateMenuAndRoutesOptions) {
|
||||
const { forbiddenComponent, roles, routes } = options;
|
||||
|
||||
let resultRoutes: RouteRecordRaw[] = routes;
|
||||
switch (mode) {
|
||||
case "backend": {
|
||||
resultRoutes = await generateRoutesByBackend(options);
|
||||
break;
|
||||
}
|
||||
case "frontend": {
|
||||
resultRoutes = await generateRoutesByFrontend(routes, roles || [], forbiddenComponent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整路由树,做以下处理:
|
||||
* 1. 对未添加redirect的路由添加redirect
|
||||
*/
|
||||
resultRoutes = mapTree(resultRoutes, (route) => {
|
||||
// 如果有redirect或者没有子路由,则直接返回
|
||||
if (route.redirect || !route.children || route.children.length === 0) {
|
||||
return route;
|
||||
}
|
||||
const firstChild = route.children[0];
|
||||
|
||||
// 如果子路由不是以/开头,则直接返回,这种情况需要计算全部父级的path才能得出正确的path,这里不做处理
|
||||
if (!firstChild?.path || !firstChild.path.startsWith("/")) {
|
||||
return route;
|
||||
}
|
||||
|
||||
route.redirect = firstChild.path;
|
||||
return route;
|
||||
});
|
||||
|
||||
return resultRoutes;
|
||||
}
|
||||
|
||||
export { generateAccessible };
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Global authority directive
|
||||
* Used for fine-grained control of component permissions
|
||||
* @Example v-access:role="[ROLE_NAME]" or v-access:role="ROLE_NAME"
|
||||
* @Example v-access:code="[ROLE_CODE]" or v-access:code="ROLE_CODE"
|
||||
*/
|
||||
import type { App, Directive, DirectiveBinding } from 'vue';
|
||||
|
||||
import { useAccess } from './use-access';
|
||||
|
||||
function isAccessible(
|
||||
el: Element,
|
||||
binding: DirectiveBinding<string | string[]>,
|
||||
) {
|
||||
const { accessMode, hasAccessByCodes, hasAccessByRoles } = useAccess();
|
||||
|
||||
const value = binding.value;
|
||||
|
||||
if (!value) return;
|
||||
const authMethod =
|
||||
accessMode.value === 'frontend' && binding.arg === 'role'
|
||||
? hasAccessByRoles
|
||||
: hasAccessByCodes;
|
||||
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
|
||||
if (!authMethod(values)) {
|
||||
el?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const mounted = (el: Element, binding: DirectiveBinding<string | string[]>) => {
|
||||
isAccessible(el, binding);
|
||||
};
|
||||
|
||||
const authDirective: Directive = {
|
||||
mounted,
|
||||
};
|
||||
|
||||
export function registerAccessDirective(app: App) {
|
||||
app.directive('access', authDirective);
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export { default as AccessControl } from './access-control.vue';
|
||||
export * from './accessible';
|
||||
export * from './directive';
|
||||
export * from './use-access';
|
|
@ -0,0 +1,52 @@
|
|||
import { computed } from "vue";
|
||||
|
||||
import { preferences, updatePreferences } from "@vben/preferences";
|
||||
import { useAccessStore, useUserStore } from "@vben/stores";
|
||||
|
||||
function useAccess() {
|
||||
const accessStore = useAccessStore();
|
||||
const userStore = useUserStore();
|
||||
const accessMode = computed(() => {
|
||||
return preferences.app.accessMode;
|
||||
});
|
||||
|
||||
/**
|
||||
* 基于角色判断是否有权限
|
||||
* @description: Determine whether there is permission,The role is judged by the user's role
|
||||
* @param roles
|
||||
*/
|
||||
function hasAccessByRoles(roles: string[]) {
|
||||
const userRoleSet = new Set(userStore.userRoles);
|
||||
const intersection = roles.filter((item) => userRoleSet.has(item));
|
||||
return intersection.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于权限码判断是否有权限
|
||||
* @description: Determine whether there is permission,The permission code is judged by the user's permission code
|
||||
* @param codes
|
||||
*/
|
||||
function hasAccessByCodes(codes: string[]) {
|
||||
const userCodesSet = new Set(accessStore.accessCodes);
|
||||
|
||||
const intersection = codes.filter((item) => userCodesSet.has(item));
|
||||
return intersection.length > 0;
|
||||
}
|
||||
|
||||
async function toggleAccessMode() {
|
||||
updatePreferences({
|
||||
app: {
|
||||
accessMode: preferences.app.accessMode === "frontend" ? "backend" : "frontend"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
accessMode,
|
||||
hasAccessByCodes,
|
||||
hasAccessByRoles,
|
||||
toggleAccessMode
|
||||
};
|
||||
}
|
||||
|
||||
export { useAccess };
|
|
@ -0,0 +1,200 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Component } from "vue";
|
||||
|
||||
import type { AnyPromiseFunction } from "/@/vben/types";
|
||||
|
||||
import { computed, ref, unref, useAttrs, watch } from "vue";
|
||||
|
||||
import { LoaderCircle } from "/@/vben/icons";
|
||||
|
||||
import { get, isEqual, isFunction } from "/@/vben/shared/utils";
|
||||
|
||||
import { objectOmit } from "@vueuse/core";
|
||||
|
||||
type OptionsItem = {
|
||||
[name: string]: any;
|
||||
children?: OptionsItem[];
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** 组件 */
|
||||
component: Component;
|
||||
/** 是否将value从数字转为string */
|
||||
numberToString?: boolean;
|
||||
/** 获取options数据的函数 */
|
||||
api?: (arg?: any) => Promise<OptionsItem[] | Record<string, any>>;
|
||||
/** 传递给api的参数 */
|
||||
params?: Record<string, any>;
|
||||
/** 从api返回的结果中提取options数组的字段名 */
|
||||
resultField?: string;
|
||||
/** label字段名 */
|
||||
labelField?: string;
|
||||
/** children字段名,需要层级数据的组件可用 */
|
||||
childrenField?: string;
|
||||
/** value字段名 */
|
||||
valueField?: string;
|
||||
/** 组件接收options数据的属性名 */
|
||||
optionsPropName?: string;
|
||||
/** 是否立即调用api */
|
||||
immediate?: boolean;
|
||||
/** 每次`visibleEvent`事件发生时都重新请求数据 */
|
||||
alwaysLoad?: boolean;
|
||||
/** 在api请求之前的回调函数 */
|
||||
beforeFetch?: AnyPromiseFunction<any, any>;
|
||||
/** 在api请求之后的回调函数 */
|
||||
afterFetch?: AnyPromiseFunction<any, any>;
|
||||
/** 直接传入选项数据,也作为api返回空数据时的后备数据 */
|
||||
options?: OptionsItem[];
|
||||
/** 组件的插槽名称,用来显示一个"加载中"的图标 */
|
||||
loadingSlot?: string;
|
||||
/** 触发api请求的事件名 */
|
||||
visibleEvent?: string;
|
||||
/** 组件的v-model属性名,默认为modelValue。部分组件可能为value */
|
||||
modelPropName?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: "ApiComponent", inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
labelField: "label",
|
||||
valueField: "value",
|
||||
childrenField: "",
|
||||
optionsPropName: "options",
|
||||
resultField: "",
|
||||
visibleEvent: "",
|
||||
numberToString: false,
|
||||
params: () => ({}),
|
||||
immediate: true,
|
||||
alwaysLoad: false,
|
||||
loadingSlot: "",
|
||||
beforeFetch: undefined,
|
||||
afterFetch: undefined,
|
||||
modelPropName: "modelValue",
|
||||
api: undefined,
|
||||
options: () => []
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
optionsChange: [OptionsItem[]];
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel({ default: "" });
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const refOptions = ref<OptionsItem[]>([]);
|
||||
const loading = ref(false);
|
||||
// 首次是否加载过了
|
||||
const isFirstLoaded = ref(false);
|
||||
|
||||
const getOptions = computed(() => {
|
||||
const { labelField, valueField, childrenField, numberToString } = props;
|
||||
|
||||
const refOptionsData = unref(refOptions);
|
||||
|
||||
function transformData(data: OptionsItem[]): OptionsItem[] {
|
||||
return data.map((item) => {
|
||||
const value = get(item, valueField);
|
||||
return {
|
||||
...objectOmit(item, [labelField, valueField, childrenField]),
|
||||
label: get(item, labelField),
|
||||
value: numberToString ? `${value}` : value,
|
||||
...(childrenField && item[childrenField] ? { children: transformData(item[childrenField]) } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const data: OptionsItem[] = transformData(refOptionsData);
|
||||
|
||||
return data.length > 0 ? data : props.options;
|
||||
});
|
||||
|
||||
const bindProps = computed(() => {
|
||||
return {
|
||||
[props.modelPropName]: unref(modelValue),
|
||||
[props.optionsPropName]: unref(getOptions),
|
||||
[`onUpdate:${props.modelPropName}`]: (val: string) => {
|
||||
modelValue.value = val;
|
||||
},
|
||||
...objectOmit(attrs, [`onUpdate:${props.modelPropName}`]),
|
||||
...(props.visibleEvent
|
||||
? {
|
||||
[props.visibleEvent]: handleFetchForVisible
|
||||
}
|
||||
: {})
|
||||
};
|
||||
});
|
||||
|
||||
async function fetchApi() {
|
||||
let { api, beforeFetch, afterFetch, params, resultField } = props;
|
||||
|
||||
if (!api || !isFunction(api) || loading.value) {
|
||||
return;
|
||||
}
|
||||
refOptions.value = [];
|
||||
try {
|
||||
loading.value = true;
|
||||
if (beforeFetch && isFunction(beforeFetch)) {
|
||||
params = (await beforeFetch(params)) || params;
|
||||
}
|
||||
let res = await api(params);
|
||||
if (afterFetch && isFunction(afterFetch)) {
|
||||
res = (await afterFetch(res)) || res;
|
||||
}
|
||||
isFirstLoaded.value = true;
|
||||
if (Array.isArray(res)) {
|
||||
refOptions.value = res;
|
||||
emitChange();
|
||||
return;
|
||||
}
|
||||
if (resultField) {
|
||||
refOptions.value = get(res, resultField) || [];
|
||||
}
|
||||
emitChange();
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
// reset status
|
||||
isFirstLoaded.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchForVisible(visible: boolean) {
|
||||
if (visible) {
|
||||
if (props.alwaysLoad) {
|
||||
await fetchApi();
|
||||
} else if (!props.immediate && !unref(isFirstLoaded)) {
|
||||
await fetchApi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.params,
|
||||
(value, oldValue) => {
|
||||
if (isEqual(value, oldValue)) {
|
||||
return;
|
||||
}
|
||||
fetchApi();
|
||||
},
|
||||
{ deep: true, immediate: props.immediate }
|
||||
);
|
||||
|
||||
function emitChange() {
|
||||
emit("optionsChange", unref(getOptions));
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<component :is="component" v-bind="bindProps" :placeholder="$attrs.placeholder">
|
||||
<template v-for="item in Object.keys($slots)" #[item]="data">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
<template v-if="loadingSlot && loading" #[loadingSlot]>
|
||||
<LoaderCircle class="animate-spin" />
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
|
@ -0,0 +1 @@
|
|||
export { default as ApiComponent } from './api-component.vue';
|
|
@ -0,0 +1,19 @@
|
|||
import type { CaptchaPoint } from '../types';
|
||||
|
||||
import { reactive } from 'vue';
|
||||
|
||||
export function useCaptchaPoints() {
|
||||
const points = reactive<CaptchaPoint[]>([]);
|
||||
function addPoint(point: CaptchaPoint) {
|
||||
points.push(point);
|
||||
}
|
||||
|
||||
function clearPoints() {
|
||||
points.splice(0, points.length);
|
||||
}
|
||||
return {
|
||||
addPoint,
|
||||
clearPoints,
|
||||
points,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export { default as PointSelectionCaptcha } from './point-selection-captcha/index.vue';
|
||||
export { default as PointSelectionCaptchaCard } from './point-selection-captcha/index.vue';
|
||||
|
||||
export { default as SliderCaptcha } from './slider-captcha/index.vue';
|
||||
export { default as SliderRotateCaptcha } from './slider-rotate-captcha/index.vue';
|
||||
export type * from './types';
|
|
@ -0,0 +1,176 @@
|
|||
<script setup lang="ts">
|
||||
import type { CaptchaPoint, PointSelectionCaptchaProps } from '../types';
|
||||
|
||||
import { RotateCw } from '/@/vben/icons';
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { VbenButton, VbenIconButton } from '/@/vben/shadcn-ui';
|
||||
|
||||
import { useCaptchaPoints } from '../hooks/useCaptchaPoints';
|
||||
import CaptchaCard from './point-selection-captcha-card.vue';
|
||||
|
||||
const props = withDefaults(defineProps<PointSelectionCaptchaProps>(), {
|
||||
height: '220px',
|
||||
hintImage: '',
|
||||
hintText: '',
|
||||
paddingX: '12px',
|
||||
paddingY: '16px',
|
||||
showConfirm: false,
|
||||
title: '',
|
||||
width: '300px',
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
click: [CaptchaPoint];
|
||||
confirm: [Array<CaptchaPoint>, clear: () => void];
|
||||
refresh: [];
|
||||
}>();
|
||||
const { addPoint, clearPoints, points } = useCaptchaPoints();
|
||||
|
||||
if (!props.hintImage && !props.hintText) {
|
||||
console.warn('At least one of hint image or hint text must be provided');
|
||||
}
|
||||
|
||||
const POINT_OFFSET = 11;
|
||||
|
||||
function getElementPosition(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + window.scrollX,
|
||||
y: rect.top + window.scrollY,
|
||||
};
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
try {
|
||||
const dom = e.currentTarget as HTMLElement;
|
||||
if (!dom) throw new Error('Element not found');
|
||||
|
||||
const { x: domX, y: domY } = getElementPosition(dom);
|
||||
|
||||
const mouseX = e.clientX + window.scrollX;
|
||||
const mouseY = e.clientY + window.scrollY;
|
||||
|
||||
if (typeof mouseX !== 'number' || typeof mouseY !== 'number') {
|
||||
throw new TypeError('Mouse coordinates not found');
|
||||
}
|
||||
|
||||
const xPos = mouseX - domX;
|
||||
const yPos = mouseY - domY;
|
||||
|
||||
const rect = dom.getBoundingClientRect();
|
||||
|
||||
// 点击位置边界校验
|
||||
if (xPos < 0 || yPos < 0 || xPos > rect.width || yPos > rect.height) {
|
||||
console.warn('Click position is out of the valid range');
|
||||
return;
|
||||
}
|
||||
|
||||
const x = Math.ceil(xPos);
|
||||
const y = Math.ceil(yPos);
|
||||
|
||||
const point = {
|
||||
i: points.length,
|
||||
t: Date.now(),
|
||||
x,
|
||||
y,
|
||||
};
|
||||
|
||||
addPoint(point);
|
||||
|
||||
emit('click', point);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
} catch (error) {
|
||||
console.error('Error in handleClick:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try {
|
||||
clearPoints();
|
||||
} catch (error) {
|
||||
console.error('Error in clear:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
try {
|
||||
clear();
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
console.error('Error in handleRefresh:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!props.showConfirm) return;
|
||||
try {
|
||||
emit('confirm', points, clear);
|
||||
} catch (error) {
|
||||
console.error('Error in handleConfirm:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<CaptchaCard
|
||||
:captcha-image="captchaImage"
|
||||
:height="height"
|
||||
:padding-x="paddingX"
|
||||
:padding-y="paddingY"
|
||||
:title="title"
|
||||
:width="width"
|
||||
@click="handleClick"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title">{{ $t('ui.captcha.title') }}</slot>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<VbenIconButton
|
||||
:aria-label="$t('ui.captcha.refreshAriaLabel')"
|
||||
class="ml-1"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<RotateCw class="size-5" />
|
||||
</VbenIconButton>
|
||||
<VbenButton
|
||||
v-if="showConfirm"
|
||||
:aria-label="$t('ui.captcha.confirmAriaLabel')"
|
||||
class="ml-2"
|
||||
size="sm"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ $t('ui.captcha.confirm') }}
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-for="(point, index) in points"
|
||||
:key="index"
|
||||
:aria-label="$t('ui.captcha.pointAriaLabel') + (index + 1)"
|
||||
:style="{
|
||||
top: `${point.y - POINT_OFFSET}px`,
|
||||
left: `${point.x - POINT_OFFSET}px`,
|
||||
}"
|
||||
class="bg-primary text-primary-50 border-primary-50 absolute z-20 flex h-5 w-5 cursor-default items-center justify-center rounded-full border-2"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<template #footer>
|
||||
<img
|
||||
v-if="hintImage"
|
||||
:alt="$t('ui.captcha.alt')"
|
||||
:src="hintImage"
|
||||
class="border-border h-10 w-full rounded border"
|
||||
/>
|
||||
<div
|
||||
v-else-if="hintText"
|
||||
class="border-border flex-center h-10 w-full rounded border"
|
||||
>
|
||||
{{ `${$t('ui.captcha.clickInOrder')}` + `【${hintText}】` }}
|
||||
</div>
|
||||
</template>
|
||||
</CaptchaCard>
|
||||
</template>
|
|
@ -0,0 +1,84 @@
|
|||
<script setup lang="ts">
|
||||
import type { PointSelectionCaptchaCardProps } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '/@/vben/shadcn-ui';
|
||||
|
||||
const props = withDefaults(defineProps<PointSelectionCaptchaCardProps>(), {
|
||||
height: '220px',
|
||||
paddingX: '12px',
|
||||
paddingY: '16px',
|
||||
title: '',
|
||||
width: '300px',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [MouseEvent];
|
||||
}>();
|
||||
|
||||
const parseValue = (value: number | string) => {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
const rootStyles = computed(() => ({
|
||||
padding: `${parseValue(props.paddingY)}px ${parseValue(props.paddingX)}px`,
|
||||
width: `${parseValue(props.width) + parseValue(props.paddingX) * 2}px`,
|
||||
}));
|
||||
|
||||
const captchaStyles = computed(() => {
|
||||
return {
|
||||
height: `${parseValue(props.height)}px`,
|
||||
width: `${parseValue(props.width)}px`,
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
emit('click', e);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Card :style="rootStyles" aria-labelledby="captcha-title" role="region">
|
||||
<CardHeader class="p-0">
|
||||
<CardTitle id="captcha-title" class="flex items-center justify-between">
|
||||
<template v-if="$slots.title">
|
||||
<slot name="title">{{ $t('ui.captcha.title') }}</slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ title }}</span>
|
||||
</template>
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0">
|
||||
<img
|
||||
v-show="captchaImage"
|
||||
:alt="$t('ui.captcha.alt')"
|
||||
:src="captchaImage"
|
||||
:style="captchaStyles"
|
||||
class="relative z-10"
|
||||
@click="handleClick"
|
||||
/>
|
||||
<div class="absolute inset-0">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="mt-2 flex justify-between p-0">
|
||||
<slot name="footer"></slot>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
|
@ -0,0 +1,244 @@
|
|||
<script setup lang="ts">
|
||||
import type {
|
||||
CaptchaVerifyPassingData,
|
||||
SliderCaptchaProps,
|
||||
SliderRotateVerifyPassingData,
|
||||
} from '../types';
|
||||
|
||||
import { reactive, unref, useTemplateRef, watch, watchEffect } from 'vue';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { cn } from '/@/vben/shared/utils';
|
||||
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
|
||||
import SliderCaptchaAction from './slider-captcha-action.vue';
|
||||
import SliderCaptchaBar from './slider-captcha-bar.vue';
|
||||
import SliderCaptchaContent from './slider-captcha-content.vue';
|
||||
|
||||
const props = withDefaults(defineProps<SliderCaptchaProps>(), {
|
||||
actionStyle: () => ({}),
|
||||
barStyle: () => ({}),
|
||||
contentStyle: () => ({}),
|
||||
isSlot: false,
|
||||
successText: '',
|
||||
text: '',
|
||||
wrapperStyle: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
end: [MouseEvent | TouchEvent];
|
||||
move: [SliderRotateVerifyPassingData];
|
||||
start: [MouseEvent | TouchEvent];
|
||||
success: [CaptchaVerifyPassingData];
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<boolean>({ default: false });
|
||||
|
||||
const state = reactive({
|
||||
endTime: 0,
|
||||
isMoving: false,
|
||||
isPassing: false,
|
||||
moveDistance: 0,
|
||||
startTime: 0,
|
||||
toLeft: false,
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
resume,
|
||||
});
|
||||
|
||||
const wrapperRef = useTemplateRef<HTMLDivElement>('wrapperRef');
|
||||
const barRef = useTemplateRef<typeof SliderCaptchaBar>('barRef');
|
||||
const contentRef = useTemplateRef<typeof SliderCaptchaContent>('contentRef');
|
||||
const actionRef = useTemplateRef<typeof SliderCaptchaAction>('actionRef');
|
||||
|
||||
watch(
|
||||
() => state.isPassing,
|
||||
(isPassing) => {
|
||||
if (isPassing) {
|
||||
const { endTime, startTime } = state;
|
||||
const time = (endTime - startTime) / 1000;
|
||||
emit('success', { isPassing, time: time.toFixed(1) });
|
||||
modelValue.value = isPassing;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
state.isPassing = !!modelValue.value;
|
||||
});
|
||||
|
||||
function getEventPageX(e: MouseEvent | TouchEvent): number {
|
||||
if ('pageX' in e) {
|
||||
return e.pageX;
|
||||
} else if ('touches' in e && e.touches[0]) {
|
||||
return e.touches[0].pageX;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function handleDragStart(e: MouseEvent | TouchEvent) {
|
||||
if (state.isPassing) {
|
||||
return;
|
||||
}
|
||||
if (!actionRef.value) return;
|
||||
emit('start', e);
|
||||
|
||||
state.moveDistance =
|
||||
getEventPageX(e) -
|
||||
Number.parseInt(
|
||||
actionRef.value.getStyle().left.replace('px', '') || '0',
|
||||
10,
|
||||
);
|
||||
state.startTime = Date.now();
|
||||
state.isMoving = true;
|
||||
}
|
||||
|
||||
function getOffset(actionEl: HTMLDivElement) {
|
||||
const wrapperWidth = wrapperRef.value?.offsetWidth ?? 220;
|
||||
const actionWidth = actionEl?.offsetWidth ?? 40;
|
||||
const offset = wrapperWidth - actionWidth - 6;
|
||||
return { actionWidth, offset, wrapperWidth };
|
||||
}
|
||||
|
||||
function handleDragMoving(e: MouseEvent | TouchEvent) {
|
||||
const { isMoving, moveDistance } = state;
|
||||
if (isMoving) {
|
||||
const actionEl = unref(actionRef);
|
||||
const barEl = unref(barRef);
|
||||
if (!actionEl || !barEl) return;
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
|
||||
const moveX = getEventPageX(e) - moveDistance;
|
||||
|
||||
emit('move', {
|
||||
event: e,
|
||||
moveDistance,
|
||||
moveX,
|
||||
});
|
||||
if (moveX > 0 && moveX <= offset) {
|
||||
actionEl.setLeft(`${moveX}px`);
|
||||
barEl.setWidth(`${moveX + actionWidth / 2}px`);
|
||||
} else if (moveX > offset) {
|
||||
actionEl.setLeft(`${wrapperWidth - actionWidth}px`);
|
||||
barEl.setWidth(`${wrapperWidth - actionWidth / 2}px`);
|
||||
if (!props.isSlot) {
|
||||
checkPass();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: MouseEvent | TouchEvent) {
|
||||
const { isMoving, isPassing, moveDistance } = state;
|
||||
if (isMoving && !isPassing) {
|
||||
emit('end', e);
|
||||
const actionEl = actionRef.value;
|
||||
const barEl = unref(barRef);
|
||||
if (!actionEl || !barEl) return;
|
||||
const moveX = getEventPageX(e) - moveDistance;
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
|
||||
if (moveX < offset) {
|
||||
if (props.isSlot) {
|
||||
setTimeout(() => {
|
||||
if (modelValue.value) {
|
||||
const contentEl = unref(contentRef);
|
||||
if (contentEl) {
|
||||
contentEl.getEl().style.width = `${Number.parseInt(barEl.getEl().style.width)}px`;
|
||||
}
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
} else {
|
||||
actionEl.setLeft(`${wrapperWidth - actionWidth}px`);
|
||||
barEl.setWidth(`${wrapperWidth - actionWidth / 2}px`);
|
||||
checkPass();
|
||||
}
|
||||
state.isMoving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkPass() {
|
||||
if (props.isSlot) {
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
state.endTime = Date.now();
|
||||
state.isPassing = true;
|
||||
state.isMoving = false;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
state.isMoving = false;
|
||||
state.isPassing = false;
|
||||
state.moveDistance = 0;
|
||||
state.toLeft = false;
|
||||
state.startTime = 0;
|
||||
state.endTime = 0;
|
||||
const actionEl = unref(actionRef);
|
||||
const barEl = unref(barRef);
|
||||
const contentEl = unref(contentRef);
|
||||
if (!actionEl || !barEl || !contentEl) return;
|
||||
|
||||
contentEl.getEl().style.width = '100%';
|
||||
state.toLeft = true;
|
||||
useTimeoutFn(() => {
|
||||
state.toLeft = false;
|
||||
actionEl.setLeft('0');
|
||||
barEl.setWidth('0');
|
||||
}, 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn(
|
||||
'border-border bg-background-deep relative flex h-10 w-full items-center overflow-hidden rounded-md border text-center',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:style="wrapperStyle"
|
||||
@mouseleave="handleDragOver"
|
||||
@mousemove="handleDragMoving"
|
||||
@mouseup="handleDragOver"
|
||||
@touchend="handleDragOver"
|
||||
@touchmove="handleDragMoving"
|
||||
>
|
||||
<SliderCaptchaBar
|
||||
ref="barRef"
|
||||
:bar-style="barStyle"
|
||||
:to-left="state.toLeft"
|
||||
/>
|
||||
<SliderCaptchaContent
|
||||
ref="contentRef"
|
||||
:content-style="contentStyle"
|
||||
:is-passing="state.isPassing"
|
||||
:success-text="successText || $t('ui.captcha.sliderSuccessText')"
|
||||
:text="text || $t('ui.captcha.sliderDefaultText')"
|
||||
>
|
||||
<template v-if="$slots.text" #text>
|
||||
<slot :is-passing="state.isPassing" name="text"></slot>
|
||||
</template>
|
||||
</SliderCaptchaContent>
|
||||
|
||||
<SliderCaptchaAction
|
||||
ref="actionRef"
|
||||
:action-style="actionStyle"
|
||||
:is-passing="state.isPassing"
|
||||
:to-left="state.toLeft"
|
||||
@mousedown="handleDragStart"
|
||||
@touchstart="handleDragStart"
|
||||
>
|
||||
<template v-if="$slots.actionIcon" #icon>
|
||||
<slot :is-passing="state.isPassing" name="actionIcon"></slot>
|
||||
</template>
|
||||
</SliderCaptchaAction>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,65 @@
|
|||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import { Check, ChevronsRight } from '/@/vben/icons';
|
||||
|
||||
import { Slot } from '/@/vben/shadcn-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
actionStyle: CSSProperties;
|
||||
isPassing: boolean;
|
||||
toLeft: boolean;
|
||||
}>();
|
||||
|
||||
const actionRef = useTemplateRef<HTMLDivElement>('actionRef');
|
||||
|
||||
const left = ref('0');
|
||||
|
||||
const style = computed(() => {
|
||||
const { actionStyle } = props;
|
||||
return {
|
||||
...actionStyle,
|
||||
left: left.value,
|
||||
};
|
||||
});
|
||||
|
||||
const isDragging = computed(() => {
|
||||
const currentLeft = Number.parseInt(left.value as string);
|
||||
|
||||
return currentLeft > 10 && !props.isPassing;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getEl: () => {
|
||||
return actionRef.value;
|
||||
},
|
||||
getStyle: () => {
|
||||
return actionRef?.value?.style;
|
||||
},
|
||||
setLeft: (val: string) => {
|
||||
left.value = val;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="actionRef"
|
||||
:class="{
|
||||
'transition-width !left-0 duration-300': toLeft,
|
||||
'rounded-md': isDragging,
|
||||
}"
|
||||
:style="style"
|
||||
class="bg-background dark:bg-accent absolute left-0 top-0 flex h-full cursor-move items-center justify-center px-3.5 shadow-md"
|
||||
name="captcha-action"
|
||||
>
|
||||
<Slot :is-passing="isPassing" class="text-foreground/60 size-4">
|
||||
<slot name="icon">
|
||||
<ChevronsRight v-if="!isPassing" />
|
||||
<Check v-else />
|
||||
</slot>
|
||||
</Slot>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,40 @@
|
|||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
barStyle: CSSProperties;
|
||||
toLeft: boolean;
|
||||
}>();
|
||||
|
||||
const barRef = useTemplateRef<HTMLDivElement>('barRef');
|
||||
|
||||
const width = ref('0');
|
||||
|
||||
const style = computed(() => {
|
||||
const { barStyle } = props;
|
||||
return {
|
||||
...barStyle,
|
||||
width: width.value,
|
||||
};
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getEl: () => {
|
||||
return barRef.value;
|
||||
},
|
||||
setWidth: (val: string) => {
|
||||
width.value = val;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="barRef"
|
||||
:class="toLeft && 'transition-width !w-0 duration-300'"
|
||||
:style="style"
|
||||
class="bg-success absolute h-full"
|
||||
></div>
|
||||
</template>
|
|
@ -0,0 +1,53 @@
|
|||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
|
||||
import { VbenSpineText } from '/@/vben/shadcn-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
contentStyle: CSSProperties;
|
||||
isPassing: boolean;
|
||||
successText: string;
|
||||
text: string;
|
||||
}>();
|
||||
|
||||
const contentRef = useTemplateRef<HTMLDivElement>('contentRef');
|
||||
|
||||
const style = computed(() => {
|
||||
const { contentStyle } = props;
|
||||
|
||||
return {
|
||||
...contentStyle,
|
||||
};
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getEl: () => {
|
||||
return contentRef.value;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="contentRef"
|
||||
:class="{
|
||||
[$style.success]: isPassing,
|
||||
}"
|
||||
:style="style"
|
||||
class="absolute top-0 flex size-full select-none items-center justify-center text-xs"
|
||||
>
|
||||
<slot name="text">
|
||||
<VbenSpineText class="flex h-full items-center">
|
||||
{{ isPassing ? successText : text }}
|
||||
</VbenSpineText>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.success {
|
||||
-webkit-text-fill-color: hsl(0deg 0% 98%);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,213 @@
|
|||
<script setup lang="ts">
|
||||
import type {
|
||||
CaptchaVerifyPassingData,
|
||||
SliderCaptchaActionType,
|
||||
SliderRotateCaptchaProps,
|
||||
SliderRotateVerifyPassingData,
|
||||
} from '../types';
|
||||
|
||||
import { computed, reactive, unref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
|
||||
import SliderCaptcha from '../slider-captcha/index.vue';
|
||||
|
||||
const props = withDefaults(defineProps<SliderRotateCaptchaProps>(), {
|
||||
defaultTip: '',
|
||||
diffDegree: 20,
|
||||
imageSize: 260,
|
||||
maxDegree: 300,
|
||||
minDegree: 120,
|
||||
src: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [CaptchaVerifyPassingData];
|
||||
}>();
|
||||
|
||||
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
|
||||
|
||||
const state = reactive({
|
||||
currentRotate: 0,
|
||||
dragging: false,
|
||||
endTime: 0,
|
||||
imgStyle: {},
|
||||
isPassing: false,
|
||||
randomRotate: 0,
|
||||
showTip: false,
|
||||
startTime: 0,
|
||||
toOrigin: false,
|
||||
});
|
||||
|
||||
const modalValue = defineModel<boolean>({ default: false });
|
||||
|
||||
watch(
|
||||
() => state.isPassing,
|
||||
(isPassing) => {
|
||||
if (isPassing) {
|
||||
const { endTime, startTime } = state;
|
||||
const time = (endTime - startTime) / 1000;
|
||||
emit('success', { isPassing, time: time.toFixed(1) });
|
||||
}
|
||||
modalValue.value = isPassing;
|
||||
},
|
||||
);
|
||||
|
||||
const getImgWrapStyleRef = computed(() => {
|
||||
const { imageSize, imageWrapperStyle } = props;
|
||||
return {
|
||||
height: `${imageSize}px`,
|
||||
width: `${imageSize}px`,
|
||||
...imageWrapperStyle,
|
||||
};
|
||||
});
|
||||
|
||||
const getFactorRef = computed(() => {
|
||||
const { maxDegree, minDegree } = props;
|
||||
if (minDegree > maxDegree) {
|
||||
console.warn('minDegree should not be greater than maxDegree');
|
||||
}
|
||||
|
||||
if (minDegree === maxDegree) {
|
||||
return Math.floor(1 + Math.random() * 1) / 10 + 1;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
function handleStart() {
|
||||
state.startTime = Date.now();
|
||||
}
|
||||
|
||||
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
|
||||
state.dragging = true;
|
||||
const { imageSize, maxDegree } = props;
|
||||
const { moveX } = data;
|
||||
const denominator = imageSize!;
|
||||
if (denominator === 0) {
|
||||
return;
|
||||
}
|
||||
const currentRotate = Math.ceil(
|
||||
(moveX / denominator) * 1.5 * maxDegree! * unref(getFactorRef),
|
||||
);
|
||||
state.currentRotate = currentRotate;
|
||||
setImgRotate(state.randomRotate - currentRotate);
|
||||
}
|
||||
|
||||
function handleImgOnLoad() {
|
||||
const { maxDegree, minDegree } = props;
|
||||
const ranRotate = Math.floor(
|
||||
minDegree! + Math.random() * (maxDegree! - minDegree!),
|
||||
); // 生成随机角度
|
||||
state.randomRotate = ranRotate;
|
||||
setImgRotate(ranRotate);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
const { currentRotate, randomRotate } = state;
|
||||
const { diffDegree } = props;
|
||||
|
||||
if (Math.abs(randomRotate - currentRotate) >= (diffDegree || 20)) {
|
||||
setImgRotate(randomRotate);
|
||||
state.toOrigin = true;
|
||||
useTimeoutFn(() => {
|
||||
state.toOrigin = false;
|
||||
state.showTip = true;
|
||||
// 时间与动画时间保持一致
|
||||
}, 300);
|
||||
} else {
|
||||
checkPass();
|
||||
}
|
||||
state.showTip = true;
|
||||
state.dragging = false;
|
||||
}
|
||||
|
||||
function setImgRotate(deg: number) {
|
||||
state.imgStyle = {
|
||||
transform: `rotateZ(${deg}deg)`,
|
||||
};
|
||||
}
|
||||
|
||||
function checkPass() {
|
||||
state.isPassing = true;
|
||||
state.endTime = Date.now();
|
||||
}
|
||||
|
||||
function resume() {
|
||||
state.showTip = false;
|
||||
const basicEl = unref(slideBarRef);
|
||||
if (!basicEl) {
|
||||
return;
|
||||
}
|
||||
state.isPassing = false;
|
||||
|
||||
basicEl.resume();
|
||||
handleImgOnLoad();
|
||||
}
|
||||
|
||||
const imgCls = computed(() => {
|
||||
return state.toOrigin ? ['transition-transform duration-300'] : [];
|
||||
});
|
||||
|
||||
const verifyTip = computed(() => {
|
||||
return state.isPassing
|
||||
? $t('ui.captcha.sliderRotateSuccessTip', [
|
||||
((state.endTime - state.startTime) / 1000).toFixed(1),
|
||||
])
|
||||
: $t('ui.captcha.sliderRotateFailTip');
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
resume,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div
|
||||
:style="getImgWrapStyleRef"
|
||||
class="border-border relative cursor-pointer overflow-hidden rounded-full border shadow-md"
|
||||
>
|
||||
<img
|
||||
:class="imgCls"
|
||||
:src="src"
|
||||
:style="state.imgStyle"
|
||||
alt="verify"
|
||||
class="w-full rounded-full"
|
||||
@click="resume"
|
||||
@load="handleImgOnLoad"
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-3 left-0 z-10 block h-7 w-full text-center text-xs leading-[30px] text-white"
|
||||
>
|
||||
<div
|
||||
v-if="state.showTip"
|
||||
:class="{
|
||||
'bg-success/80': state.isPassing,
|
||||
'bg-destructive/80': !state.isPassing,
|
||||
}"
|
||||
>
|
||||
{{ verifyTip }}
|
||||
</div>
|
||||
<div v-if="!state.dragging" class="bg-black/30">
|
||||
{{ defaultTip || $t('ui.captcha.sliderRotateDefaultTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SliderCaptcha
|
||||
ref="slideBarRef"
|
||||
v-model="modalValue"
|
||||
class="mt-5"
|
||||
is-slot
|
||||
@end="handleDragEnd"
|
||||
@move="handleDragBarMove"
|
||||
@start="handleStart"
|
||||
>
|
||||
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
|
||||
<slot :name="key" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</SliderCaptcha>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,175 @@
|
|||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { ClassType } from '/@/vben/types';
|
||||
|
||||
export interface CaptchaData {
|
||||
/**
|
||||
* x
|
||||
*/
|
||||
x: number;
|
||||
/**
|
||||
* y
|
||||
*/
|
||||
y: number;
|
||||
/**
|
||||
* 时间戳
|
||||
*/
|
||||
t: number;
|
||||
}
|
||||
export interface CaptchaPoint extends CaptchaData {
|
||||
/**
|
||||
* 数据索引
|
||||
*/
|
||||
i: number;
|
||||
}
|
||||
export interface PointSelectionCaptchaCardProps {
|
||||
/**
|
||||
* 验证码图片
|
||||
*/
|
||||
captchaImage: string;
|
||||
/**
|
||||
* 验证码图片高度
|
||||
* @default '220px'
|
||||
*/
|
||||
height?: number | string;
|
||||
/**
|
||||
* 水平内边距
|
||||
* @default '12px'
|
||||
*/
|
||||
paddingX?: number | string;
|
||||
/**
|
||||
* 垂直内边距
|
||||
* @default '16px'
|
||||
*/
|
||||
paddingY?: number | string;
|
||||
/**
|
||||
* 标题
|
||||
* @default '请按图依次点击'
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 验证码图片宽度
|
||||
* @default '300px'
|
||||
*/
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export interface PointSelectionCaptchaProps
|
||||
extends PointSelectionCaptchaCardProps {
|
||||
/**
|
||||
* 是否展示确定按钮
|
||||
* @default false
|
||||
*/
|
||||
showConfirm?: boolean;
|
||||
/**
|
||||
* 提示图片
|
||||
* @default ''
|
||||
*/
|
||||
hintImage?: string;
|
||||
/**
|
||||
* 提示文本
|
||||
* @default ''
|
||||
*/
|
||||
hintText?: string;
|
||||
}
|
||||
|
||||
export interface SliderCaptchaProps {
|
||||
class?: ClassType;
|
||||
/**
|
||||
* @description 滑块的样式
|
||||
* @default {}
|
||||
*/
|
||||
actionStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 滑块条的样式
|
||||
* @default {}
|
||||
*/
|
||||
barStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 内容的样式
|
||||
* @default {}
|
||||
*/
|
||||
contentStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 组件的样式
|
||||
* @default {}
|
||||
*/
|
||||
wrapperStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 是否作为插槽使用,用于联动组件,可参考旋转校验组件
|
||||
* @default false
|
||||
*/
|
||||
isSlot?: boolean;
|
||||
|
||||
/**
|
||||
* @description 验证成功的提示
|
||||
* @default '验证通过'
|
||||
*/
|
||||
successText?: string;
|
||||
|
||||
/**
|
||||
* @description 提示文字
|
||||
* @default '请按住滑块拖动'
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface SliderRotateCaptchaProps {
|
||||
/**
|
||||
* @description 旋转的角度
|
||||
* @default 20
|
||||
*/
|
||||
diffDegree?: number;
|
||||
|
||||
/**
|
||||
* @description 图片的宽度
|
||||
* @default 260
|
||||
*/
|
||||
imageSize?: number;
|
||||
|
||||
/**
|
||||
* @description 图片的样式
|
||||
* @default {}
|
||||
*/
|
||||
imageWrapperStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 最大旋转角度
|
||||
* @default 270
|
||||
*/
|
||||
maxDegree?: number;
|
||||
|
||||
/**
|
||||
* @description 最小旋转角度
|
||||
* @default 90
|
||||
*/
|
||||
minDegree?: number;
|
||||
|
||||
/**
|
||||
* @description 图片的地址
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* @description 默认提示文本
|
||||
*/
|
||||
defaultTip?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaVerifyPassingData {
|
||||
isPassing: boolean;
|
||||
time: number | string;
|
||||
}
|
||||
|
||||
export interface SliderCaptchaActionType {
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
export interface SliderRotateVerifyPassingData {
|
||||
event: MouseEvent | TouchEvent;
|
||||
moveDistance: number;
|
||||
moveX: number;
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
<script lang="ts" setup>
|
||||
import type { ColPageProps } from './types';
|
||||
|
||||
import { computed, ref, useSlots } from 'vue';
|
||||
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '/@/vben/shadcn-ui';
|
||||
|
||||
import Page from '../page/page.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ColPage',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<ColPageProps>(), {
|
||||
leftWidth: 30,
|
||||
rightWidth: 70,
|
||||
resizable: true,
|
||||
});
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { leftWidth: _, ...delegated } = props;
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const delegatedSlots = computed(() => {
|
||||
const resultSlots: string[] = [];
|
||||
|
||||
for (const key of Object.keys(slots)) {
|
||||
if (!['default', 'left'].includes(key)) {
|
||||
resultSlots.push(key);
|
||||
}
|
||||
}
|
||||
return resultSlots;
|
||||
});
|
||||
|
||||
const leftPanelRef = ref<InstanceType<typeof ResizablePanel>>();
|
||||
|
||||
function expandLeft() {
|
||||
leftPanelRef.value?.expand();
|
||||
}
|
||||
|
||||
function collapseLeft() {
|
||||
leftPanelRef.value?.collapse();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
expandLeft,
|
||||
collapseLeft,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Page v-bind="delegatedProps">
|
||||
<!-- 继承默认的slot -->
|
||||
<template
|
||||
v-for="slotName in delegatedSlots"
|
||||
:key="slotName"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot :name="slotName" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
|
||||
<ResizablePanelGroup class="w-full" direction="horizontal">
|
||||
<ResizablePanel
|
||||
ref="leftPanelRef"
|
||||
:collapsed-size="leftCollapsedWidth"
|
||||
:collapsible="leftCollapsible"
|
||||
:default-size="leftWidth"
|
||||
:max-size="leftMaxWidth"
|
||||
:min-size="leftMinWidth"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<slot
|
||||
name="left"
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
expand: expandLeft,
|
||||
collapse: collapseLeft,
|
||||
}"
|
||||
></slot>
|
||||
</template>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
v-if="resizable"
|
||||
:style="{ backgroundColor: splitLine ? undefined : 'transparent' }"
|
||||
:with-handle="splitHandle"
|
||||
/>
|
||||
<ResizablePanel
|
||||
:collapsed-size="rightCollapsedWidth"
|
||||
:collapsible="rightCollapsible"
|
||||
:default-size="rightWidth"
|
||||
:max-size="rightMaxWidth"
|
||||
:min-size="rightMinWidth"
|
||||
>
|
||||
<template #default>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</Page>
|
||||
</template>
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ColPage } from './col-page.vue';
|
||||
export * from './types';
|
|
@ -0,0 +1,26 @@
|
|||
import type { PageProps } from '../page/types';
|
||||
|
||||
export interface ColPageProps extends PageProps {
|
||||
/**
|
||||
* 左侧宽度
|
||||
* @default 30
|
||||
*/
|
||||
leftWidth?: number;
|
||||
leftMinWidth?: number;
|
||||
leftMaxWidth?: number;
|
||||
leftCollapsedWidth?: number;
|
||||
leftCollapsible?: boolean;
|
||||
/**
|
||||
* 右侧宽度
|
||||
* @default 70
|
||||
*/
|
||||
rightWidth?: number;
|
||||
rightMinWidth?: number;
|
||||
rightCollapsedWidth?: number;
|
||||
rightMaxWidth?: number;
|
||||
rightCollapsible?: boolean;
|
||||
|
||||
resizable?: boolean;
|
||||
splitLine?: boolean;
|
||||
splitHandle?: boolean;
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CountToProps } from './types';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { isString } from '/@/vben/shared/utils';
|
||||
|
||||
import { TransitionPresets, useTransition } from '@vueuse/core';
|
||||
|
||||
const props = withDefaults(defineProps<CountToProps>(), {
|
||||
startVal: 0,
|
||||
duration: 2000,
|
||||
separator: ',',
|
||||
decimal: '.',
|
||||
decimals: 0,
|
||||
delay: 0,
|
||||
transition: () => TransitionPresets.easeOutExpo,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['started', 'finished']);
|
||||
|
||||
const lastValue = ref(props.startVal);
|
||||
|
||||
onMounted(() => {
|
||||
lastValue.value = props.endVal;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.endVal,
|
||||
(val) => {
|
||||
lastValue.value = val;
|
||||
},
|
||||
);
|
||||
|
||||
const currentValue = useTransition(lastValue, {
|
||||
delay: computed(() => props.delay),
|
||||
duration: computed(() => props.duration),
|
||||
disabled: computed(() => props.disabled),
|
||||
transition: computed(() => {
|
||||
return isString(props.transition)
|
||||
? TransitionPresets[props.transition]
|
||||
: props.transition;
|
||||
}),
|
||||
onStarted() {
|
||||
emit('started');
|
||||
},
|
||||
onFinished() {
|
||||
emit('finished');
|
||||
},
|
||||
});
|
||||
|
||||
const numMain = computed(() => {
|
||||
const result = currentValue.value
|
||||
.toFixed(props.decimals)
|
||||
.split('.')[0]
|
||||
?.replaceAll(/\B(?=(\d{3})+(?!\d))/g, props.separator);
|
||||
return result;
|
||||
});
|
||||
|
||||
const numDec = computed(() => {
|
||||
return (
|
||||
props.decimal + currentValue.value.toFixed(props.decimals).split('.')[1]
|
||||
);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="count-to" v-bind="$attrs">
|
||||
<slot name="prefix">
|
||||
<div
|
||||
class="count-to-prefix"
|
||||
:style="prefixStyle"
|
||||
:class="prefixClass"
|
||||
v-if="prefix"
|
||||
>
|
||||
{{ prefix }}
|
||||
</div>
|
||||
</slot>
|
||||
<div class="count-to-main" :class="mainClass" :style="mainStyle">
|
||||
<span>{{ numMain }}</span>
|
||||
<span
|
||||
class="count-to-main-decimal"
|
||||
v-if="decimals > 0"
|
||||
:class="decimalClass"
|
||||
:style="decimalStyle"
|
||||
>
|
||||
{{ numDec }}
|
||||
</span>
|
||||
</div>
|
||||
<slot name="suffix">
|
||||
<div
|
||||
class="count-to-suffix"
|
||||
:style="suffixStyle"
|
||||
:class="suffixClass"
|
||||
v-if="suffix"
|
||||
>
|
||||
{{ suffix }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.count-to {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
&-prefix {
|
||||
// font-size: 1rem;
|
||||
}
|
||||
|
||||
&-suffix {
|
||||
// font-size: 1rem;
|
||||
}
|
||||
|
||||
&-main {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
// font-size: 1.5rem;
|
||||
|
||||
&-decimal {
|
||||
// font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,2 @@
|
|||
export { default as CountTo } from './count-to.vue';
|
||||
export * from './types';
|
|
@ -0,0 +1,53 @@
|
|||
import type { CubicBezierPoints, EasingFunction } from '@vueuse/core';
|
||||
|
||||
import type { StyleValue } from 'vue';
|
||||
|
||||
import { TransitionPresets as TransitionPresetsData } from '@vueuse/core';
|
||||
|
||||
export type TransitionPresets = keyof typeof TransitionPresetsData;
|
||||
|
||||
export const TransitionPresetsKeys = Object.keys(
|
||||
TransitionPresetsData,
|
||||
) as TransitionPresets[];
|
||||
|
||||
export interface CountToProps {
|
||||
/** 初始值 */
|
||||
startVal?: number;
|
||||
/** 当前值 */
|
||||
endVal: number;
|
||||
/** 是否禁用动画 */
|
||||
disabled?: boolean;
|
||||
/** 延迟动画开始的时间 */
|
||||
delay?: number;
|
||||
/** 持续时间 */
|
||||
duration?: number;
|
||||
/** 小数位数 */
|
||||
decimals?: number;
|
||||
/** 小数点 */
|
||||
decimal?: string;
|
||||
/** 分隔符 */
|
||||
separator?: string;
|
||||
/** 前缀 */
|
||||
prefix?: string;
|
||||
/** 后缀 */
|
||||
suffix?: string;
|
||||
/** 过渡效果 */
|
||||
transition?: CubicBezierPoints | EasingFunction | TransitionPresets;
|
||||
/** 整数部分的类名 */
|
||||
mainClass?: string;
|
||||
/** 小数部分的类名 */
|
||||
decimalClass?: string;
|
||||
/** 前缀部分的类名 */
|
||||
prefixClass?: string;
|
||||
/** 后缀部分的类名 */
|
||||
suffixClass?: string;
|
||||
|
||||
/** 整数部分的样式 */
|
||||
mainStyle?: StyleValue;
|
||||
/** 小数部分的样式 */
|
||||
decimalStyle?: StyleValue;
|
||||
/** 前缀部分的样式 */
|
||||
prefixStyle?: StyleValue;
|
||||
/** 后缀部分的样式 */
|
||||
suffixStyle?: StyleValue;
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, ref, watchEffect } from 'vue';
|
||||
|
||||
import { VbenTooltip } from '/@/vben/shadcn-ui';
|
||||
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 是否启用点击文本展开全部
|
||||
* @default false
|
||||
*/
|
||||
expand?: boolean;
|
||||
/**
|
||||
* 文本最大行数
|
||||
* @default 1
|
||||
*/
|
||||
line?: number;
|
||||
/**
|
||||
* 文本最大宽度
|
||||
* @default '100%'
|
||||
*/
|
||||
maxWidth?: number | string;
|
||||
/**
|
||||
* 提示框位置
|
||||
* @default 'top'
|
||||
*/
|
||||
placement?: 'bottom' | 'left' | 'right' | 'top';
|
||||
/**
|
||||
* 是否启用文本提示框
|
||||
* @default true
|
||||
*/
|
||||
tooltip?: boolean;
|
||||
/**
|
||||
* 提示框背景颜色,优先级高于 overlayStyle
|
||||
*/
|
||||
tooltipBackgroundColor?: string;
|
||||
/**
|
||||
* 提示文本字体颜色,优先级高于 overlayStyle
|
||||
*/
|
||||
tooltipColor?: string;
|
||||
/**
|
||||
* 提示文本字体大小,单位px,优先级高于 overlayStyle
|
||||
*/
|
||||
tooltipFontSize?: number;
|
||||
/**
|
||||
* 提示框内容最大宽度,单位px,默认不设置时,提示文本内容自动与展示文本宽度保持一致
|
||||
*/
|
||||
tooltipMaxWidth?: number;
|
||||
/**
|
||||
* 提示框内容区域样式
|
||||
* @default { textAlign: 'justify' }
|
||||
*/
|
||||
tooltipOverlayStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
expand: false,
|
||||
line: 1,
|
||||
maxWidth: '100%',
|
||||
placement: 'top',
|
||||
tooltip: true,
|
||||
tooltipBackgroundColor: '',
|
||||
tooltipColor: '',
|
||||
tooltipFontSize: 14,
|
||||
tooltipMaxWidth: undefined,
|
||||
tooltipOverlayStyle: () => ({ textAlign: 'justify' }),
|
||||
});
|
||||
const emit = defineEmits<{ expandChange: [boolean] }>();
|
||||
|
||||
const textMaxWidth = computed(() => {
|
||||
if (typeof props.maxWidth === 'number') {
|
||||
return `${props.maxWidth}px`;
|
||||
}
|
||||
return props.maxWidth;
|
||||
});
|
||||
const ellipsis = ref();
|
||||
const isExpand = ref(false);
|
||||
const defaultTooltipMaxWidth = ref();
|
||||
|
||||
const { width: eleWidth } = useElementSize(ellipsis);
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (props.tooltip && eleWidth.value) {
|
||||
defaultTooltipMaxWidth.value =
|
||||
props.tooltipMaxWidth ?? eleWidth.value + 24;
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
function onExpand() {
|
||||
isExpand.value = !isExpand.value;
|
||||
emit('expandChange', isExpand.value);
|
||||
}
|
||||
|
||||
function handleExpand() {
|
||||
props.expand && onExpand();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VbenTooltip
|
||||
:content-style="{
|
||||
...tooltipOverlayStyle,
|
||||
maxWidth: `${defaultTooltipMaxWidth}px`,
|
||||
fontSize: `${tooltipFontSize}px`,
|
||||
color: tooltipColor,
|
||||
backgroundColor: tooltipBackgroundColor,
|
||||
}"
|
||||
:disabled="!props.tooltip || isExpand"
|
||||
:side="placement"
|
||||
>
|
||||
<slot name="tooltip">
|
||||
<slot></slot>
|
||||
</slot>
|
||||
|
||||
<template #trigger>
|
||||
<div
|
||||
ref="ellipsis"
|
||||
:class="{
|
||||
'!cursor-pointer': expand,
|
||||
['block truncate']: line === 1,
|
||||
[$style.ellipsisMultiLine]: line > 1,
|
||||
}"
|
||||
:style="{
|
||||
'-webkit-line-clamp': isExpand ? '' : line,
|
||||
'max-width': textMaxWidth,
|
||||
}"
|
||||
class="cursor-text overflow-hidden"
|
||||
@click="handleExpand"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
</VbenTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.ellipsisMultiLine {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1 @@
|
|||
export { default as EllipsisText } from './ellipsis-text.vue';
|
|
@ -0,0 +1,304 @@
|
|||
<script setup lang="ts">
|
||||
import type { VNode } from 'vue';
|
||||
|
||||
import { computed, ref, watch, watchEffect } from 'vue';
|
||||
|
||||
import { usePagination } from '/@/vben/hooks';
|
||||
import { EmptyIcon, Grip, listIcons } from '/@/vben/icons';
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
VbenIcon,
|
||||
VbenIconButton,
|
||||
VbenPopover,
|
||||
} from '/@/vben/shadcn-ui';
|
||||
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core';
|
||||
|
||||
import { fetchIconsData } from './icons';
|
||||
|
||||
interface Props {
|
||||
pageSize?: number;
|
||||
/** 图标集的名字 */
|
||||
prefix?: string;
|
||||
/** 是否自动请求API以获得图标集的数据.提供prefix时有效 */
|
||||
autoFetchApi?: boolean;
|
||||
/**
|
||||
* 图标列表
|
||||
*/
|
||||
icons?: string[];
|
||||
/** Input组件 */
|
||||
inputComponent?: VNode;
|
||||
/** 图标插槽名,预览图标将被渲染到此插槽中 */
|
||||
iconSlot?: string;
|
||||
/** input组件的值属性名称 */
|
||||
modelValueProp?: string;
|
||||
/** 图标样式 */
|
||||
iconClass?: string;
|
||||
type?: 'icon' | 'input';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
prefix: 'ant-design',
|
||||
pageSize: 36,
|
||||
icons: () => [],
|
||||
iconSlot: 'default',
|
||||
iconClass: 'size-4',
|
||||
autoFetchApi: true,
|
||||
modelValueProp: 'modelValue',
|
||||
inputComponent: undefined,
|
||||
type: 'input',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [string];
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel({ default: '', type: String });
|
||||
|
||||
const visible = ref(false);
|
||||
const currentSelect = ref('');
|
||||
const currentPage = ref(1);
|
||||
const keyword = ref('');
|
||||
const keywordDebounce = refDebounced(keyword, 300);
|
||||
const innerIcons = ref<string[]>([]);
|
||||
|
||||
watchDebounced(
|
||||
() => props.prefix,
|
||||
async (prefix) => {
|
||||
if (prefix && prefix !== 'svg' && props.autoFetchApi) {
|
||||
innerIcons.value = await fetchIconsData(prefix);
|
||||
}
|
||||
},
|
||||
{ immediate: true, debounce: 500, maxWait: 1000 },
|
||||
);
|
||||
|
||||
const currentList = computed(() => {
|
||||
try {
|
||||
if (props.prefix) {
|
||||
if (
|
||||
props.prefix !== 'svg' &&
|
||||
props.autoFetchApi &&
|
||||
props.icons.length === 0
|
||||
) {
|
||||
return innerIcons.value;
|
||||
}
|
||||
const icons = listIcons('', props.prefix);
|
||||
if (icons.length === 0) {
|
||||
console.warn(`No icons found for prefix: ${props.prefix}`);
|
||||
}
|
||||
return icons;
|
||||
} else {
|
||||
return props.icons;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load icons:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const showList = computed(() => {
|
||||
return currentList.value.filter((item) =>
|
||||
item.includes(keywordDebounce.value),
|
||||
);
|
||||
});
|
||||
|
||||
const { paginationList, total, setCurrentPage } = usePagination(
|
||||
showList,
|
||||
props.pageSize,
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
currentSelect.value = modelValue.value;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => currentSelect.value,
|
||||
(v) => {
|
||||
emit('change', v);
|
||||
},
|
||||
);
|
||||
|
||||
const handleClick = (icon: string) => {
|
||||
currentSelect.value = icon;
|
||||
modelValue.value = icon;
|
||||
close();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
function toggleOpenState() {
|
||||
visible.value = !visible.value;
|
||||
}
|
||||
|
||||
function open() {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function onKeywordChange(v: string) {
|
||||
keyword.value = v;
|
||||
}
|
||||
|
||||
const searchInputProps = computed(() => {
|
||||
return {
|
||||
placeholder: $t('ui.iconPicker.search'),
|
||||
[props.modelValueProp]: keyword.value,
|
||||
[`onUpdate:${props.modelValueProp}`]: onKeywordChange,
|
||||
class: 'mx-2',
|
||||
};
|
||||
});
|
||||
|
||||
defineExpose({ toggleOpenState, open, close });
|
||||
</script>
|
||||
<template>
|
||||
<VbenPopover
|
||||
v-model:open="visible"
|
||||
:content-props="{ align: 'end', alignOffset: -11, sideOffset: 8 }"
|
||||
content-class="p-0 pt-3"
|
||||
>
|
||||
<template #trigger>
|
||||
<template v-if="props.type === 'input'">
|
||||
<component
|
||||
v-if="props.inputComponent"
|
||||
:is="inputComponent"
|
||||
:[modelValueProp]="currentSelect"
|
||||
:placeholder="$t('ui.iconPicker.placeholder')"
|
||||
role="combobox"
|
||||
:aria-label="$t('ui.iconPicker.placeholder')"
|
||||
aria-expanded="visible"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template #[iconSlot]>
|
||||
<VbenIcon
|
||||
:icon="currentSelect || Grip"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</component>
|
||||
<div class="relative w-full" v-else>
|
||||
<Input
|
||||
v-bind="$attrs"
|
||||
v-model="currentSelect"
|
||||
:placeholder="$t('ui.iconPicker.placeholder')"
|
||||
class="h-8 w-full pr-8"
|
||||
role="combobox"
|
||||
:aria-label="$t('ui.iconPicker.placeholder')"
|
||||
aria-expanded="visible"
|
||||
/>
|
||||
<VbenIcon
|
||||
:icon="currentSelect || Grip"
|
||||
class="absolute right-1 top-1 size-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VbenIcon
|
||||
:icon="currentSelect || Grip"
|
||||
v-else
|
||||
class="size-4"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</template>
|
||||
<div class="mb-2 flex w-full">
|
||||
<component
|
||||
v-if="inputComponent"
|
||||
:is="inputComponent"
|
||||
v-bind="searchInputProps"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
class="mx-2 h-8 w-full"
|
||||
:placeholder="$t('ui.iconPicker.search')"
|
||||
v-model="keyword"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="paginationList.length > 0">
|
||||
<div class="grid max-h-[360px] w-full grid-cols-6 justify-items-center">
|
||||
<VbenIconButton
|
||||
v-for="(item, index) in paginationList"
|
||||
:key="index"
|
||||
:tooltip="item"
|
||||
tooltip-side="top"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<VbenIcon
|
||||
:class="{
|
||||
'text-primary transition-all': currentSelect === item,
|
||||
}"
|
||||
:icon="item"
|
||||
/>
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="total >= pageSize"
|
||||
class="flex-center flex justify-end overflow-hidden border-t py-2 pr-3"
|
||||
>
|
||||
<Pagination
|
||||
:items-per-page="36"
|
||||
:sibling-count="1"
|
||||
:total="total"
|
||||
show-edges
|
||||
size="small"
|
||||
@update:page="handlePageChange"
|
||||
>
|
||||
<PaginationList
|
||||
v-slot="{ items }"
|
||||
class="flex w-full items-center gap-1"
|
||||
>
|
||||
<PaginationFirst class="size-5" />
|
||||
<PaginationPrev class="size-5" />
|
||||
<template v-for="(item, index) in items">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:key="index"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<Button
|
||||
:variant="item.value === currentPage ? 'default' : 'outline'"
|
||||
class="size-5 p-0 text-sm"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis
|
||||
v-else
|
||||
:key="item.type"
|
||||
:index="index"
|
||||
class="size-5"
|
||||
/>
|
||||
</template>
|
||||
<PaginationNext class="size-5" />
|
||||
<PaginationLast class="size-5" />
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex-col-center text-muted-foreground min-h-[150px] w-full">
|
||||
<EmptyIcon class="size-10" />
|
||||
<div class="mt-1 text-sm">{{ $t('common.noData') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</VbenPopover>
|
||||
</template>
|
|
@ -0,0 +1,56 @@
|
|||
import type { Recordable } from '/@/vben/types';
|
||||
|
||||
/**
|
||||
* 一个缓存对象,在不刷新页面时,无需重复请求远程接口
|
||||
*/
|
||||
export const ICONS_MAP: Recordable<string[]> = {};
|
||||
|
||||
interface IconifyResponse {
|
||||
prefix: string;
|
||||
total: number;
|
||||
title: string;
|
||||
uncategorized?: string[];
|
||||
categories?: Recordable<string[]>;
|
||||
aliases?: Recordable<string>;
|
||||
}
|
||||
|
||||
const PENDING_REQUESTS: Recordable<Promise<string[]>> = {};
|
||||
|
||||
/**
|
||||
* 通过Iconify接口获取图标集数据。
|
||||
* 同一时间多个图标选择器同时请求同一个图标集时,实际上只会发起一次请求(所有请求共享同一份结果)。
|
||||
* 请求结果会被缓存,刷新页面前同一个图标集不会再次请求
|
||||
* @param prefix 图标集名称
|
||||
* @returns 图标集中包含的所有图标名称
|
||||
*/
|
||||
export async function fetchIconsData(prefix: string): Promise<string[]> {
|
||||
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
|
||||
return ICONS_MAP[prefix];
|
||||
}
|
||||
if (Reflect.has(PENDING_REQUESTS, prefix) && PENDING_REQUESTS[prefix]) {
|
||||
return PENDING_REQUESTS[prefix];
|
||||
}
|
||||
PENDING_REQUESTS[prefix] = (async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000 * 10);
|
||||
const response: IconifyResponse = await fetch(
|
||||
`https://api.iconify.design/collection?prefix=${prefix}`,
|
||||
{ signal: controller.signal },
|
||||
).then((res) => res.json());
|
||||
clearTimeout(timeoutId);
|
||||
const list = response.uncategorized || [];
|
||||
if (response.categories) {
|
||||
for (const category in response.categories) {
|
||||
list.push(...(response.categories[category] || []));
|
||||
}
|
||||
}
|
||||
ICONS_MAP[prefix] = list.map((v) => `${prefix}:${v}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch icons for prefix ${prefix}:`, error);
|
||||
return [] as string[];
|
||||
}
|
||||
return ICONS_MAP[prefix];
|
||||
})();
|
||||
return PENDING_REQUESTS[prefix];
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default as IconPicker } from './icon-picker.vue';
|
|
@ -0,0 +1,27 @@
|
|||
export * from './api-component';
|
||||
export * from './captcha';
|
||||
export * from './col-page';
|
||||
export * from './count-to';
|
||||
export * from './ellipsis-text';
|
||||
export * from './icon-picker';
|
||||
export * from './json-viewer';
|
||||
export * from './loading';
|
||||
export * from './page';
|
||||
export * from './resize';
|
||||
export * from './tippy';
|
||||
export * from '/@/vben/form-ui';
|
||||
export * from '/@/vben/popup-ui';
|
||||
|
||||
// 给文档用
|
||||
export {
|
||||
VbenButton,
|
||||
VbenButtonGroup,
|
||||
VbenCheckButtonGroup,
|
||||
VbenCountToAnimator,
|
||||
VbenInputPassword,
|
||||
VbenLoading,
|
||||
VbenPinInput,
|
||||
VbenSpinner,
|
||||
} from '/@/vben/shadcn-ui';
|
||||
|
||||
export { globalShareState } from '/@/vben/shared/global-state';
|
|
@ -0,0 +1,3 @@
|
|||
export { default as JsonViewer } from './index.vue';
|
||||
|
||||
export * from './types';
|
|
@ -0,0 +1,98 @@
|
|||
<script lang="ts" setup>
|
||||
import type { SetupContext } from 'vue';
|
||||
|
||||
import type { Recordable } from '/@/vben/types';
|
||||
|
||||
import type {
|
||||
JsonViewerAction,
|
||||
JsonViewerProps,
|
||||
JsonViewerToggle,
|
||||
JsonViewerValue,
|
||||
} from './types';
|
||||
|
||||
import { computed, useAttrs } from 'vue';
|
||||
// @ts-ignore
|
||||
import VueJsonViewer from 'vue-json-viewer';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { isBoolean } from '/@/vben/shared/utils';
|
||||
|
||||
defineOptions({ name: 'JsonViewer' });
|
||||
|
||||
const props = withDefaults(defineProps<JsonViewerProps>(), {
|
||||
expandDepth: 1,
|
||||
copyable: false,
|
||||
sort: false,
|
||||
boxed: false,
|
||||
theme: 'default-json-theme',
|
||||
expanded: false,
|
||||
previewMode: false,
|
||||
showArrayIndex: true,
|
||||
showDoubleQuotes: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
copied: [event: JsonViewerAction];
|
||||
keyClick: [key: string];
|
||||
toggle: [param: JsonViewerToggle];
|
||||
valueClick: [value: JsonViewerValue];
|
||||
}>();
|
||||
|
||||
const attrs: SetupContext['attrs'] = useAttrs();
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.classList.contains('jv-item')
|
||||
) {
|
||||
const pathNode = event.target.closest('.jv-push');
|
||||
if (!pathNode || !pathNode.hasAttribute('path')) {
|
||||
return;
|
||||
}
|
||||
const param: JsonViewerValue = {
|
||||
path: '',
|
||||
value: '',
|
||||
depth: 0,
|
||||
el: event.target,
|
||||
};
|
||||
|
||||
param.path = pathNode.getAttribute('path') || '';
|
||||
param.depth = Number(pathNode.getAttribute('depth')) || 0;
|
||||
|
||||
param.value = event.target.textContent || undefined;
|
||||
param.value = JSON.parse(param.value);
|
||||
emit('valueClick', param);
|
||||
}
|
||||
emit('click', event);
|
||||
}
|
||||
|
||||
const bindProps = computed<Recordable<any>>(() => {
|
||||
const copyable = {
|
||||
copyText: $t('ui.jsonViewer.copy'),
|
||||
copiedText: $t('ui.jsonViewer.copied'),
|
||||
timeout: 2000,
|
||||
...(isBoolean(props.copyable) ? {} : props.copyable),
|
||||
};
|
||||
|
||||
return {
|
||||
...props,
|
||||
...attrs,
|
||||
onCopied: (event: JsonViewerAction) => emit('copied', event),
|
||||
onKeyclick: (key: string) => emit('keyClick', key),
|
||||
onClick: (event: MouseEvent) => handleClick(event),
|
||||
copyable: props.copyable ? copyable : false,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<VueJsonViewer v-bind="bindProps">
|
||||
<template #copy="slotProps">
|
||||
<slot name="copy" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</VueJsonViewer>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
@use './style.scss';
|
||||
</style>
|
|
@ -0,0 +1,98 @@
|
|||
.default-json-theme {
|
||||
font-family: Consolas, Menlo, Courier, monospace;
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
background: hsl(var(--background));
|
||||
|
||||
&.jv-container.boxed {
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.jv-ellipsis {
|
||||
display: inline-block;
|
||||
padding: 0 4px 2px;
|
||||
font-size: 0.9em;
|
||||
line-height: 0.9;
|
||||
color: hsl(var(--secondary-foreground));
|
||||
vertical-align: 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: hsl(var(--secondary));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.jv-button {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.jv-key {
|
||||
color: hsl(var(--heavy-foreground));
|
||||
}
|
||||
|
||||
.jv-item {
|
||||
&.jv-array {
|
||||
color: hsl(var(--heavy-foreground));
|
||||
}
|
||||
|
||||
&.jv-boolean {
|
||||
color: hsl(var(--red-400));
|
||||
}
|
||||
|
||||
&.jv-function {
|
||||
color: hsl(var(--destructive-foreground));
|
||||
}
|
||||
|
||||
&.jv-number {
|
||||
color: hsl(var(--info-foreground));
|
||||
}
|
||||
|
||||
&.jv-number-float {
|
||||
color: hsl(var(--info-foreground));
|
||||
}
|
||||
|
||||
&.jv-number-integer {
|
||||
color: hsl(var(--info-foreground));
|
||||
}
|
||||
|
||||
&.jv-object {
|
||||
color: hsl(var(--accent-darker));
|
||||
}
|
||||
|
||||
&.jv-undefined {
|
||||
color: hsl(var(--secondary-foreground));
|
||||
}
|
||||
|
||||
&.jv-string {
|
||||
color: hsl(var(--primary));
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
&.jv-container .jv-code {
|
||||
padding: 10px;
|
||||
|
||||
&.boxed:not(.open) {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&.open {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.jv-toggle {
|
||||
&::before {
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
background: hsl(var(--accent-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
export interface JsonViewerProps {
|
||||
/** 要展示的结构数据 */
|
||||
value: any;
|
||||
/** 展开深度 */
|
||||
expandDepth?: number;
|
||||
/** 是否可复制 */
|
||||
copyable?: boolean;
|
||||
/** 是否排序 */
|
||||
sort?: boolean;
|
||||
/** 显示边框 */
|
||||
boxed?: boolean;
|
||||
/** 主题 */
|
||||
theme?: string;
|
||||
/** 是否展开 */
|
||||
expanded?: boolean;
|
||||
/** 时间格式化函数 */
|
||||
timeformat?: (time: Date | number | string) => string;
|
||||
/** 预览模式 */
|
||||
previewMode?: boolean;
|
||||
/** 显示数组索引 */
|
||||
showArrayIndex?: boolean;
|
||||
/** 显示双引号 */
|
||||
showDoubleQuotes?: boolean;
|
||||
}
|
||||
|
||||
export interface JsonViewerAction {
|
||||
action: string;
|
||||
text: string;
|
||||
trigger: HTMLElement;
|
||||
}
|
||||
|
||||
export interface JsonViewerValue {
|
||||
value: any;
|
||||
path: string;
|
||||
depth: number;
|
||||
el: HTMLElement;
|
||||
}
|
||||
|
||||
export interface JsonViewerToggle {
|
||||
/** 鼠标事件 */
|
||||
event: MouseEvent;
|
||||
/** 当前展开状态 */
|
||||
open: boolean;
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
import type { App, Directive, DirectiveBinding } from 'vue';
|
||||
|
||||
import { h, render } from 'vue';
|
||||
|
||||
import { VbenLoading, VbenSpinner } from '/@/vben/shadcn-ui';
|
||||
import { isString } from '/@/vben/shared/utils';
|
||||
|
||||
const LOADING_INSTANCE_KEY = Symbol('loading');
|
||||
const SPINNER_INSTANCE_KEY = Symbol('spinner');
|
||||
|
||||
const CLASS_NAME_RELATIVE = 'spinner-parent--relative';
|
||||
|
||||
const loadingDirective: Directive = {
|
||||
mounted(el, binding) {
|
||||
const instance = h(VbenLoading, getOptions(binding));
|
||||
render(instance, el);
|
||||
|
||||
el.classList.add(CLASS_NAME_RELATIVE);
|
||||
el[LOADING_INSTANCE_KEY] = instance;
|
||||
},
|
||||
unmounted(el) {
|
||||
const instance = el[LOADING_INSTANCE_KEY];
|
||||
el.classList.remove(CLASS_NAME_RELATIVE);
|
||||
render(null, el);
|
||||
instance.el.remove();
|
||||
|
||||
el[LOADING_INSTANCE_KEY] = null;
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const instance = el[LOADING_INSTANCE_KEY];
|
||||
const options = getOptions(binding);
|
||||
if (options && instance?.component) {
|
||||
try {
|
||||
Object.keys(options).forEach((key) => {
|
||||
instance.component.props[key] = options[key];
|
||||
});
|
||||
instance.component.update();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to update loading component in directive:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function getOptions(binding: DirectiveBinding) {
|
||||
if (binding.value === undefined) {
|
||||
return { spinning: true };
|
||||
} else if (typeof binding.value === 'boolean') {
|
||||
return { spinning: binding.value };
|
||||
} else {
|
||||
return { ...binding.value };
|
||||
}
|
||||
}
|
||||
|
||||
const spinningDirective: Directive = {
|
||||
mounted(el, binding) {
|
||||
const instance = h(VbenSpinner, getOptions(binding));
|
||||
render(instance, el);
|
||||
|
||||
el.classList.add(CLASS_NAME_RELATIVE);
|
||||
el[SPINNER_INSTANCE_KEY] = instance;
|
||||
},
|
||||
unmounted(el) {
|
||||
const instance = el[SPINNER_INSTANCE_KEY];
|
||||
el.classList.remove(CLASS_NAME_RELATIVE);
|
||||
render(null, el);
|
||||
instance.el.remove();
|
||||
|
||||
el[SPINNER_INSTANCE_KEY] = null;
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const instance = el[SPINNER_INSTANCE_KEY];
|
||||
const options = getOptions(binding);
|
||||
if (options && instance?.component) {
|
||||
try {
|
||||
Object.keys(options).forEach((key) => {
|
||||
instance.component.props[key] = options[key];
|
||||
});
|
||||
instance.component.update();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to update spinner component in directive:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
type loadingDirectiveParams = {
|
||||
/** 是否注册loading指令。如果提供一个string,则将指令注册为指定的名称 */
|
||||
loading?: boolean | string;
|
||||
/** 是否注册spinning指令。如果提供一个string,则将指令注册为指定的名称 */
|
||||
spinning?: boolean | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册loading指令
|
||||
* @param app
|
||||
* @param params
|
||||
*/
|
||||
export function registerLoadingDirective(
|
||||
app: App,
|
||||
params?: loadingDirectiveParams,
|
||||
) {
|
||||
// 注入一个样式供指令使用,确保容器是相对定位
|
||||
const style = document.createElement('style');
|
||||
style.id = CLASS_NAME_RELATIVE;
|
||||
style.innerHTML = `
|
||||
.${CLASS_NAME_RELATIVE} {
|
||||
position: relative !important;
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
if (params?.loading !== false) {
|
||||
app.directive(
|
||||
isString(params?.loading) ? params.loading : 'loading',
|
||||
loadingDirective,
|
||||
);
|
||||
}
|
||||
if (params?.spinning !== false) {
|
||||
app.directive(
|
||||
isString(params?.spinning) ? params.spinning : 'spinning',
|
||||
spinningDirective,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from './directive';
|
||||
export { default as Loading } from './loading.vue';
|
||||
export { default as Spinner } from './spinner.vue';
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts" setup>
|
||||
import { VbenLoading } from '/@/vben/shadcn-ui';
|
||||
import { cn } from '/@/vben/shared/utils';
|
||||
|
||||
interface LoadingProps {
|
||||
class?: string;
|
||||
/**
|
||||
* @zh_CN 最小加载时间
|
||||
* @en_US Minimum loading time
|
||||
*/
|
||||
minLoadingTime?: number;
|
||||
|
||||
/**
|
||||
* @zh_CN loading状态开启
|
||||
*/
|
||||
spinning?: boolean;
|
||||
/**
|
||||
* @zh_CN 文字
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'Loading' });
|
||||
const props = defineProps<LoadingProps>();
|
||||
</script>
|
||||
<template>
|
||||
<div :class="cn('relative min-h-20', props.class)">
|
||||
<slot></slot>
|
||||
<VbenLoading
|
||||
:min-loading-time="props.minLoadingTime"
|
||||
:spinning="props.spinning"
|
||||
:text="props.text"
|
||||
>
|
||||
<template v-if="$slots.icon" #icon>
|
||||
<slot name="icon"></slot>
|
||||
</template>
|
||||
</VbenLoading>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts" setup>
|
||||
import { VbenSpinner } from '/@/vben/shadcn-ui';
|
||||
import { cn } from '/@/vben/shared/utils';
|
||||
|
||||
interface SpinnerProps {
|
||||
class?: string;
|
||||
/**
|
||||
* @zh_CN 最小加载时间
|
||||
* @en_US Minimum loading time
|
||||
*/
|
||||
minLoadingTime?: number;
|
||||
/**
|
||||
* @zh_CN loading状态开启
|
||||
*/
|
||||
spinning?: boolean;
|
||||
}
|
||||
defineOptions({ name: 'Spinner' });
|
||||
const props = defineProps<SpinnerProps>();
|
||||
</script>
|
||||
<template>
|
||||
<div :class="cn('relative min-h-20', props.class)">
|
||||
<slot></slot>
|
||||
<VbenSpinner
|
||||
:min-loading-time="props.minLoadingTime"
|
||||
:spinning="props.spinning"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,89 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Page } from '..';
|
||||
|
||||
describe('page.vue', () => {
|
||||
it('renders title when passed', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
title: 'Test Title',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Test Title');
|
||||
});
|
||||
|
||||
it('renders description when passed', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
description: 'Test Description',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Test Description');
|
||||
});
|
||||
|
||||
it('renders default slot content', () => {
|
||||
const wrapper = mount(Page, {
|
||||
slots: {
|
||||
default: '<p>Default Slot Content</p>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('<p>Default Slot Content</p>');
|
||||
});
|
||||
|
||||
it('renders footer slot when showFooter is true', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
showFooter: true,
|
||||
},
|
||||
slots: {
|
||||
footer: '<p>Footer Slot Content</p>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('<p>Footer Slot Content</p>');
|
||||
});
|
||||
|
||||
it('applies the custom contentClass', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
contentClass: 'custom-class',
|
||||
},
|
||||
});
|
||||
|
||||
const contentDiv = wrapper.find('.p-4');
|
||||
expect(contentDiv.classes()).toContain('custom-class');
|
||||
});
|
||||
|
||||
it('does not render title slot if title prop is provided', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
title: 'Test Title',
|
||||
},
|
||||
slots: {
|
||||
title: '<p>Title Slot Content</p>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Title Slot Content');
|
||||
expect(wrapper.html()).not.toContain('Test Title');
|
||||
});
|
||||
|
||||
it('does not render description slot if description prop is provided', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
description: 'Test Description',
|
||||
},
|
||||
slots: {
|
||||
description: '<p>Description Slot Content</p>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Description Slot Content');
|
||||
expect(wrapper.html()).not.toContain('Test Description');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Page } from './page.vue';
|
||||
export * from './types';
|
|
@ -0,0 +1,105 @@
|
|||
<script setup lang="ts">
|
||||
import type { StyleValue } from 'vue';
|
||||
|
||||
import type { PageProps } from './types';
|
||||
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import { CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT } from '/@/vben/shared/constants';
|
||||
import { cn } from '/@/vben/shared/utils';
|
||||
|
||||
defineOptions({
|
||||
name: 'Page',
|
||||
});
|
||||
|
||||
const { autoContentHeight = false } = defineProps<PageProps>();
|
||||
|
||||
const headerHeight = ref(0);
|
||||
const footerHeight = ref(0);
|
||||
const shouldAutoHeight = ref(false);
|
||||
|
||||
const headerRef = useTemplateRef<HTMLDivElement>('headerRef');
|
||||
const footerRef = useTemplateRef<HTMLDivElement>('footerRef');
|
||||
|
||||
const contentStyle = computed<StyleValue>(() => {
|
||||
if (autoContentHeight) {
|
||||
return {
|
||||
height: `calc(var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT}) - ${headerHeight.value}px)`,
|
||||
overflowY: shouldAutoHeight.value ? 'auto' : 'unset',
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
async function calcContentHeight() {
|
||||
if (!autoContentHeight) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
headerHeight.value = headerRef.value?.offsetHeight || 0;
|
||||
footerHeight.value = footerRef.value?.offsetHeight || 0;
|
||||
setTimeout(() => {
|
||||
shouldAutoHeight.value = true;
|
||||
}, 30);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
calcContentHeight();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="
|
||||
description ||
|
||||
$slots.description ||
|
||||
title ||
|
||||
$slots.title ||
|
||||
$slots.extra
|
||||
"
|
||||
ref="headerRef"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card border-border relative flex items-end border-b px-6 py-4',
|
||||
headerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex-auto">
|
||||
<slot name="title">
|
||||
<div v-if="title" class="mb-2 flex text-lg font-semibold">
|
||||
{{ title }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<slot name="description">
|
||||
<p v-if="description" class="text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.extra">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="cn('h-full p-4', contentClass)" :style="contentStyle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
ref="footerRef"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card align-center absolute bottom-0 left-0 right-0 flex px-6 py-4',
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,11 @@
|
|||
export interface PageProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
contentClass?: string;
|
||||
/**
|
||||
* 根据content可见高度自适应
|
||||
*/
|
||||
autoContentHeight?: boolean;
|
||||
headerClass?: string;
|
||||
footerClass?: string;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { default as VResize } from './resize.vue';
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,100 @@
|
|||
import type { ComputedRef, Directive } from 'vue';
|
||||
|
||||
import { useTippy } from 'vue-tippy';
|
||||
|
||||
export default function useTippyDirective(isDark: ComputedRef<boolean>) {
|
||||
const directive: Directive = {
|
||||
mounted(el, binding, vnode) {
|
||||
const opts =
|
||||
typeof binding.value === 'string'
|
||||
? { content: binding.value }
|
||||
: binding.value || {};
|
||||
|
||||
const modifiers = Object.keys(binding.modifiers || {});
|
||||
const placement = modifiers.find((modifier) => modifier !== 'arrow');
|
||||
const withArrow = modifiers.includes('arrow');
|
||||
|
||||
if (placement) {
|
||||
opts.placement = opts.placement || placement;
|
||||
}
|
||||
|
||||
if (withArrow) {
|
||||
opts.arrow = opts.arrow === undefined ? true : opts.arrow;
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyShow) {
|
||||
opts.onShow = function (...args: any[]) {
|
||||
return vnode.props?.onTippyShow(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyShown) {
|
||||
opts.onShown = function (...args: any[]) {
|
||||
return vnode.props?.onTippyShown(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyHidden) {
|
||||
opts.onHidden = function (...args: any[]) {
|
||||
return vnode.props?.onTippyHidden(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyHide) {
|
||||
opts.onHide = function (...args: any[]) {
|
||||
return vnode.props?.onTippyHide(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyMount) {
|
||||
opts.onMount = function (...args: any[]) {
|
||||
return vnode.props?.onTippyMount(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (el.getAttribute('title') && !opts.content) {
|
||||
opts.content = el.getAttribute('title');
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
|
||||
if (el.getAttribute('content') && !opts.content) {
|
||||
opts.content = el.getAttribute('content');
|
||||
}
|
||||
|
||||
useTippy(el, opts);
|
||||
},
|
||||
unmounted(el) {
|
||||
if (el.$tippy) {
|
||||
el.$tippy.destroy();
|
||||
} else if (el._tippy) {
|
||||
el._tippy.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const opts =
|
||||
typeof binding.value === 'string'
|
||||
? { content: binding.value, theme: isDark.value ? '' : 'light' }
|
||||
: Object.assign(
|
||||
{ theme: isDark.value ? '' : 'light' },
|
||||
binding.value,
|
||||
);
|
||||
|
||||
if (el.getAttribute('title') && !opts.content) {
|
||||
opts.content = el.getAttribute('title');
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
|
||||
if (el.getAttribute('content') && !opts.content) {
|
||||
opts.content = el.getAttribute('content');
|
||||
}
|
||||
|
||||
if (el.$tippy) {
|
||||
el.$tippy.setProps(opts || {});
|
||||
} else if (el._tippy) {
|
||||
el._tippy.setProps(opts || {});
|
||||
}
|
||||
},
|
||||
};
|
||||
return directive;
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import type { DefaultProps, Props } from 'tippy.js';
|
||||
|
||||
import type { App, SetupContext } from 'vue';
|
||||
|
||||
import { h, watchEffect } from 'vue';
|
||||
import { setDefaultProps, Tippy as TippyComponent } from 'vue-tippy';
|
||||
|
||||
import { usePreferences } from '/@/vben/preferences';
|
||||
|
||||
import useTippyDirective from './directive';
|
||||
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
import 'tippy.js/dist/backdrop.css';
|
||||
import 'tippy.js/themes/light.css';
|
||||
import 'tippy.js/animations/scale.css';
|
||||
import 'tippy.js/animations/shift-toward.css';
|
||||
import 'tippy.js/animations/shift-away.css';
|
||||
import 'tippy.js/animations/perspective.css';
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
export type TippyProps = Partial<
|
||||
Props & {
|
||||
animation?:
|
||||
| 'fade'
|
||||
| 'perspective'
|
||||
| 'scale'
|
||||
| 'shift-away'
|
||||
| 'shift-toward'
|
||||
| boolean;
|
||||
theme?: 'auto' | 'dark' | 'light';
|
||||
}
|
||||
>;
|
||||
|
||||
export function initTippy(app: App<Element>, options?: DefaultProps) {
|
||||
setDefaultProps({
|
||||
allowHTML: true,
|
||||
delay: [500, 200],
|
||||
theme: isDark.value ? '' : 'light',
|
||||
...options,
|
||||
});
|
||||
if (!options || !Reflect.has(options, 'theme') || options.theme === 'auto') {
|
||||
watchEffect(() => {
|
||||
setDefaultProps({ theme: isDark.value ? '' : 'light' });
|
||||
});
|
||||
}
|
||||
|
||||
app.directive('tippy', useTippyDirective(isDark));
|
||||
}
|
||||
|
||||
export const Tippy = (props: any, { attrs, slots }: SetupContext) => {
|
||||
let theme: string = (attrs.theme as string) ?? 'auto';
|
||||
if (theme === 'auto') {
|
||||
theme = isDark.value ? '' : 'light';
|
||||
}
|
||||
if (theme === 'dark') {
|
||||
theme = '';
|
||||
}
|
||||
return h(
|
||||
TippyComponent,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
theme,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './components';
|
||||
export * from './ui';
|
|
@ -0,0 +1,14 @@
|
|||
import type { Component } from 'vue';
|
||||
|
||||
interface AboutProps {
|
||||
description?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface DescriptionItem {
|
||||
content: Component | string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type { AboutProps, DescriptionItem };
|
|
@ -0,0 +1,183 @@
|
|||
<script setup lang="ts">
|
||||
import type { AboutProps, DescriptionItem } from './about';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import {
|
||||
VBEN_DOC_URL,
|
||||
VBEN_GITHUB_URL,
|
||||
VBEN_PREVIEW_URL,
|
||||
} from '/@/vben/constants';
|
||||
|
||||
import { VbenRenderContent } from '/@/vben/shadcn-ui';
|
||||
|
||||
import { Page } from '../../components';
|
||||
|
||||
interface Props extends AboutProps {}
|
||||
|
||||
defineOptions({
|
||||
name: 'AboutUI',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
description:
|
||||
'是一个现代化开箱即用的中后台解决方案,采用最新的技术栈,包括 Vue 3.0、Vite、TailwindCSS 和 TypeScript 等前沿技术,代码规范严谨,提供丰富的配置选项,旨在为中大型项目的开发提供现成的开箱即用解决方案及丰富的示例,同时,它也是学习和深入前端技术的一个极佳示例。',
|
||||
name: 'Vben Admin',
|
||||
title: '关于项目',
|
||||
});
|
||||
|
||||
declare global {
|
||||
const __VBEN_ADMIN_METADATA__: {
|
||||
authorEmail: string;
|
||||
authorName: string;
|
||||
authorUrl: string;
|
||||
buildTime: string;
|
||||
dependencies: Record<string, string>;
|
||||
description: string;
|
||||
devDependencies: Record<string, string>;
|
||||
homepage: string;
|
||||
license: string;
|
||||
repositoryUrl: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
const renderLink = (href: string, text: string) =>
|
||||
h(
|
||||
'a',
|
||||
{ href, target: '_blank', class: 'vben-link' },
|
||||
{ default: () => text },
|
||||
);
|
||||
|
||||
const {
|
||||
authorEmail,
|
||||
authorName,
|
||||
authorUrl,
|
||||
buildTime,
|
||||
dependencies = {},
|
||||
devDependencies = {},
|
||||
homepage,
|
||||
license,
|
||||
version,
|
||||
// vite inject-metadata 插件注入的全局变量
|
||||
} = __VBEN_ADMIN_METADATA__ || {};
|
||||
|
||||
const vbenDescriptionItems: DescriptionItem[] = [
|
||||
{
|
||||
content: version,
|
||||
title: '版本号',
|
||||
},
|
||||
{
|
||||
content: license,
|
||||
title: '开源许可协议',
|
||||
},
|
||||
{
|
||||
content: buildTime,
|
||||
title: '最后构建时间',
|
||||
},
|
||||
{
|
||||
content: renderLink(homepage, '点击查看'),
|
||||
title: '主页',
|
||||
},
|
||||
{
|
||||
content: renderLink(VBEN_DOC_URL, '点击查看'),
|
||||
title: '文档地址',
|
||||
},
|
||||
{
|
||||
content: renderLink(VBEN_PREVIEW_URL, '点击查看'),
|
||||
title: '预览地址',
|
||||
},
|
||||
{
|
||||
content: renderLink(VBEN_GITHUB_URL, '点击查看'),
|
||||
title: 'Github',
|
||||
},
|
||||
{
|
||||
content: h('div', [
|
||||
renderLink(authorUrl, `${authorName} `),
|
||||
renderLink(`mailto:${authorEmail}`, authorEmail),
|
||||
]),
|
||||
title: '作者',
|
||||
},
|
||||
];
|
||||
|
||||
const dependenciesItems = Object.keys(dependencies).map((key) => ({
|
||||
content: dependencies[key],
|
||||
title: key,
|
||||
}));
|
||||
|
||||
const devDependenciesItems = Object.keys(devDependencies).map((key) => ({
|
||||
content: devDependencies[key],
|
||||
title: key,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page :title="title">
|
||||
<template #description>
|
||||
<p class="text-foreground mt-3 text-sm leading-6">
|
||||
<a :href="VBEN_GITHUB_URL" class="vben-link" target="_blank">
|
||||
{{ name }}
|
||||
</a>
|
||||
{{ description }}
|
||||
</p>
|
||||
</template>
|
||||
<div class="card-box p-5">
|
||||
<div>
|
||||
<h5 class="text-foreground text-lg">基本信息</h5>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<dl class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<template v-for="item in vbenDescriptionItems" :key="item.title">
|
||||
<div class="border-border border-t px-4 py-6 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-foreground text-sm font-medium leading-6">
|
||||
{{ item.title }}
|
||||
</dt>
|
||||
<dd class="text-foreground mt-1 text-sm leading-6 sm:mt-2">
|
||||
<VbenRenderContent :content="item.content" />
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-box mt-6 p-5">
|
||||
<div>
|
||||
<h5 class="text-foreground text-lg">生产环境依赖</h5>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<dl class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<template v-for="item in dependenciesItems" :key="item.title">
|
||||
<div class="border-border border-t px-4 py-3 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-foreground text-sm">
|
||||
{{ item.title }}
|
||||
</dt>
|
||||
<dd class="text-foreground/80 mt-1 text-sm sm:mt-2">
|
||||
<VbenRenderContent :content="item.content" />
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-box mt-6 p-5">
|
||||
<div>
|
||||
<h5 class="text-foreground text-lg">开发环境依赖</h5>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<dl class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<template v-for="item in devDependenciesItems" :key="item.title">
|
||||
<div class="border-border border-t px-4 py-3 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-foreground text-sm">
|
||||
{{ item.title }}
|
||||
</dt>
|
||||
<dd class="text-foreground/80 mt-1 text-sm sm:mt-2">
|
||||
<VbenRenderContent :content="item.content" />
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
|
@ -0,0 +1 @@
|
|||
export { default as About } from './about.vue';
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<div class="mb-7 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2
|
||||
class="text-foreground mb-3 text-3xl font-bold leading-9 tracking-tight lg:text-4xl"
|
||||
>
|
||||
<slot></slot>
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground lg:text-md text-sm">
|
||||
<slot name="desc"></slot>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,117 @@
|
|||
<script setup lang="ts">
|
||||
import type { Recordable } from '/@/vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '/@/vben/form-ui';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { useVbenForm } from '/@/vben/form-ui';
|
||||
import { VbenButton } from '/@/vben/shadcn-ui';
|
||||
|
||||
import Title from './auth-title.vue';
|
||||
|
||||
interface Props {
|
||||
formSchema: VbenFormSchema[];
|
||||
/**
|
||||
* @zh_CN 是否处于加载处理状态
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* @zh_CN 登录路径
|
||||
*/
|
||||
loginPath?: string;
|
||||
/**
|
||||
* @zh_CN 标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
subTitle?: string;
|
||||
/**
|
||||
* @zh_CN 按钮文本
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'AuthenticationCodeLogin',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
title: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [Form, formApi] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
hideRequiredMark: true,
|
||||
},
|
||||
schema: computed(() => props.formSchema),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
const values = await formApi.getValues();
|
||||
if (valid) {
|
||||
emit('submit', values);
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push(props.loginPath);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getFormApi: () => formApi,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Title>
|
||||
<slot name="title">
|
||||
{{ title || $t('authentication.welcomeBack') }} 📲
|
||||
</slot>
|
||||
<template #desc>
|
||||
<span class="text-muted-foreground">
|
||||
<slot name="subTitle">
|
||||
{{ subTitle || $t('authentication.codeSubtitle') }}
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
</Title>
|
||||
<Form />
|
||||
<VbenButton
|
||||
:class="{
|
||||
'cursor-wait': loading,
|
||||
}"
|
||||
:loading="loading"
|
||||
class="w-full"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<slot name="submitButtonText">
|
||||
{{ submitButtonText || $t('common.login') }}
|
||||
</slot>
|
||||
</VbenButton>
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,116 @@
|
|||
<script setup lang="ts">
|
||||
import type { VbenFormSchema } from '/@/vben/form-ui';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { useVbenForm } from '/@/vben/form-ui';
|
||||
import { VbenButton } from '/@/vben/shadcn-ui';
|
||||
|
||||
import Title from './auth-title.vue';
|
||||
|
||||
interface Props {
|
||||
formSchema: VbenFormSchema[];
|
||||
/**
|
||||
* @zh_CN 是否处于加载处理状态
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* @zh_CN 登录路径
|
||||
*/
|
||||
loginPath?: string;
|
||||
/**
|
||||
* @zh_CN 标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
subTitle?: string;
|
||||
/**
|
||||
* @zh_CN 按钮文本
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'ForgetPassword',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
title: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [Record<string, any>];
|
||||
}>();
|
||||
|
||||
const [Form, formApi] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
hideRequiredMark: true,
|
||||
},
|
||||
schema: computed(() => props.formSchema),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
const values = await formApi.getValues();
|
||||
if (valid) {
|
||||
emit('submit', values);
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push(props.loginPath);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getFormApi: () => formApi,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Title>
|
||||
<slot name="title">
|
||||
{{ title || $t('authentication.forgetPassword') }} 🤦🏻♂️
|
||||
</slot>
|
||||
<template #desc>
|
||||
<slot name="subTitle">
|
||||
{{ subTitle || $t('authentication.forgetPasswordSubtitle') }}
|
||||
</slot>
|
||||
</template>
|
||||
</Title>
|
||||
<Form />
|
||||
|
||||
<div>
|
||||
<VbenButton
|
||||
:class="{
|
||||
'cursor-wait': loading,
|
||||
}"
|
||||
aria-label="submit"
|
||||
class="mt-2 w-full"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<slot name="submitButtonText">
|
||||
{{ submitButtonText || $t('authentication.sendResetLink') }}
|
||||
</slot>
|
||||
</VbenButton>
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,7 @@
|
|||
export { default as AuthenticationCodeLogin } from './code-login.vue';
|
||||
export { default as AuthenticationForgetPassword } from './forget-password.vue';
|
||||
export { default as AuthenticationLoginExpiredModal } from './login-expired-modal.vue';
|
||||
export { default as AuthenticationLogin } from './login.vue';
|
||||
export { default as AuthenticationQrCodeLogin } from './qrcode-login.vue';
|
||||
export { default as AuthenticationRegister } from './register.vue';
|
||||
export type { AuthenticationProps } from './types';
|
|
@ -0,0 +1,79 @@
|
|||
<script setup lang="ts">
|
||||
import type { AuthenticationProps } from './types';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '/@/vben/popup-ui';
|
||||
import { Slot, VbenAvatar } from '/@/vben/shadcn-ui';
|
||||
|
||||
interface Props extends AuthenticationProps {
|
||||
avatar?: string;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'LoginExpiredModal',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatar: '',
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
const open = defineModel<boolean>('open');
|
||||
|
||||
const [Modal, modalApi] = useVbenModal();
|
||||
|
||||
watch(
|
||||
() => open.value,
|
||||
(val) => {
|
||||
modalApi.setState({ isOpen: val });
|
||||
},
|
||||
);
|
||||
|
||||
const getZIndex = computed(() => {
|
||||
return props.zIndex || calcZIndex();
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取最大的zIndex值
|
||||
*/
|
||||
function calcZIndex() {
|
||||
let maxZ = 0;
|
||||
const elements = document.querySelectorAll('*');
|
||||
[...elements].forEach((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const zIndex = style.getPropertyValue('z-index');
|
||||
if (zIndex && !Number.isNaN(Number.parseInt(zIndex))) {
|
||||
maxZ = Math.max(maxZ, Number.parseInt(zIndex));
|
||||
}
|
||||
});
|
||||
return maxZ + 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Modal
|
||||
:closable="false"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:footer="false"
|
||||
:fullscreen-button="false"
|
||||
:header="false"
|
||||
:z-index="getZIndex"
|
||||
class="border-none px-10 py-6 text-center shadow-xl sm:w-[600px] sm:rounded-2xl md:h-[unset]"
|
||||
>
|
||||
<VbenAvatar :src="avatar" class="mx-auto mb-6 size-20" />
|
||||
<Slot
|
||||
:show-forget-password="false"
|
||||
:show-register="false"
|
||||
:show-remember-me="false"
|
||||
:sub-title="$t('authentication.loginAgainSubTitle')"
|
||||
:title="$t('authentication.loginAgainTitle')"
|
||||
>
|
||||
<slot> </slot>
|
||||
</Slot>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,186 @@
|
|||
<script setup lang="ts">
|
||||
import type { Recordable } from '/@/vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '/@/vben/form-ui';
|
||||
|
||||
import type { AuthenticationProps } from './types';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { useVbenForm } from '/@/vben/form-ui';
|
||||
import { VbenButton, VbenCheckbox } from '/@/vben/shadcn-ui';
|
||||
|
||||
import Title from './auth-title.vue';
|
||||
import ThirdPartyLogin from './third-party-login.vue';
|
||||
|
||||
interface Props extends AuthenticationProps {
|
||||
formSchema: VbenFormSchema[];
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'AuthenticationLogin',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
codeLoginPath: '/auth/code-login',
|
||||
forgetPasswordPath: '/auth/forget-password',
|
||||
formSchema: () => [],
|
||||
loading: false,
|
||||
qrCodeLoginPath: '/auth/qrcode-login',
|
||||
registerPath: '/auth/register',
|
||||
showCodeLogin: true,
|
||||
showForgetPassword: true,
|
||||
showQrcodeLogin: true,
|
||||
showRegister: true,
|
||||
showRememberMe: true,
|
||||
showThirdPartyLogin: true,
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
title: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
const [Form, formApi] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
hideRequiredMark: true,
|
||||
},
|
||||
schema: computed(() => props.formSchema),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
const router = useRouter();
|
||||
|
||||
const REMEMBER_ME_KEY = `REMEMBER_ME_USERNAME_${location.hostname}`;
|
||||
|
||||
const localUsername = localStorage.getItem(REMEMBER_ME_KEY) || '';
|
||||
|
||||
const rememberMe = ref(!!localUsername);
|
||||
|
||||
async function handleSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
const values = await formApi.getValues();
|
||||
if (valid) {
|
||||
localStorage.setItem(
|
||||
REMEMBER_ME_KEY,
|
||||
rememberMe.value ? values?.username : '',
|
||||
);
|
||||
emit('submit', values);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGo(path: string) {
|
||||
router.push(path);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (localUsername) {
|
||||
formApi.setFieldValue('username', localUsername);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getFormApi: () => formApi,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div @keydown.enter.prevent="handleSubmit">
|
||||
<slot name="title">
|
||||
<Title>
|
||||
<slot name="title">
|
||||
{{ title || `${$t('authentication.welcomeBack')} 👋🏻` }}
|
||||
</slot>
|
||||
<template #desc>
|
||||
<span class="text-muted-foreground">
|
||||
<slot name="subTitle">
|
||||
{{ subTitle || $t('authentication.loginSubtitle') }}
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
</Title>
|
||||
</slot>
|
||||
|
||||
<Form />
|
||||
|
||||
<div
|
||||
v-if="showRememberMe || showForgetPassword"
|
||||
class="mb-6 flex justify-between"
|
||||
>
|
||||
<div class="flex-center">
|
||||
<VbenCheckbox
|
||||
v-if="showRememberMe"
|
||||
v-model:checked="rememberMe"
|
||||
name="rememberMe"
|
||||
>
|
||||
{{ $t('authentication.rememberMe') }}
|
||||
</VbenCheckbox>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="showForgetPassword"
|
||||
class="vben-link text-sm font-normal"
|
||||
@click="handleGo(forgetPasswordPath)"
|
||||
>
|
||||
{{ $t('authentication.forgetPassword') }}
|
||||
</span>
|
||||
</div>
|
||||
<VbenButton
|
||||
:class="{
|
||||
'cursor-wait': loading,
|
||||
}"
|
||||
:loading="loading"
|
||||
aria-label="login"
|
||||
class="w-full"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ submitButtonText || $t('common.login') }}
|
||||
</VbenButton>
|
||||
|
||||
<div
|
||||
v-if="showCodeLogin || showQrcodeLogin"
|
||||
class="mb-2 mt-4 flex items-center justify-between"
|
||||
>
|
||||
<VbenButton
|
||||
v-if="showCodeLogin"
|
||||
class="w-1/2"
|
||||
variant="outline"
|
||||
@click="handleGo(codeLoginPath)"
|
||||
>
|
||||
{{ $t('authentication.mobileLogin') }}
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
v-if="showQrcodeLogin"
|
||||
class="ml-4 w-1/2"
|
||||
variant="outline"
|
||||
@click="handleGo(qrCodeLoginPath)"
|
||||
>
|
||||
{{ $t('authentication.qrcodeLogin') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
|
||||
<!-- 第三方登录 -->
|
||||
<slot name="third-party-login">
|
||||
<ThirdPartyLogin v-if="showThirdPartyLogin" />
|
||||
</slot>
|
||||
|
||||
<slot name="to-register">
|
||||
<div v-if="showRegister" class="mt-3 text-center text-sm">
|
||||
{{ $t('authentication.accountTip') }}
|
||||
<span
|
||||
class="vben-link text-sm font-normal"
|
||||
@click="handleGo(registerPath)"
|
||||
>
|
||||
{{ $t('authentication.createAccount') }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,95 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { VbenButton } from '/@/vben/shadcn-ui';
|
||||
|
||||
import { useQRCode } from '@vueuse/integrations/useQRCode';
|
||||
|
||||
import Title from './auth-title.vue';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* @zh_CN 是否处于加载处理状态
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* @zh_CN 登录路径
|
||||
*/
|
||||
loginPath?: string;
|
||||
/**
|
||||
* @zh_CN 标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
subTitle?: string;
|
||||
/**
|
||||
* @zh_CN 按钮文本
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
/**
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'AuthenticationQrCodeLogin',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
description: '',
|
||||
loading: false,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
title: '',
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const text = ref('https://vben.vvbin.cn');
|
||||
|
||||
const qrcode = useQRCode(text, {
|
||||
errorCorrectionLevel: 'H',
|
||||
margin: 4,
|
||||
});
|
||||
|
||||
function goToLogin() {
|
||||
router.push(props.loginPath);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Title>
|
||||
<slot name="title">
|
||||
{{ title || $t('authentication.welcomeBack') }} 📱
|
||||
</slot>
|
||||
<template #desc>
|
||||
<span class="text-muted-foreground">
|
||||
<slot name="subTitle">
|
||||
{{ subTitle || $t('authentication.qrcodeSubtitle') }}
|
||||
</slot>
|
||||
</span>
|
||||
</template>
|
||||
</Title>
|
||||
|
||||
<div class="flex-col-center mt-6">
|
||||
<img :src="qrcode" alt="qrcode" class="w-1/2" />
|
||||
<p class="text-muted-foreground mt-4 text-sm">
|
||||
<slot name="description">
|
||||
{{ description || $t('authentication.qrcodePrompt') }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,121 @@
|
|||
<script setup lang="ts">
|
||||
import type { Recordable } from '/@/vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '/@/vben/form-ui';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { useVbenForm } from '/@/vben/form-ui';
|
||||
import { VbenButton } from '/@/vben/shadcn-ui';
|
||||
|
||||
import Title from './auth-title.vue';
|
||||
|
||||
interface Props {
|
||||
formSchema: VbenFormSchema[];
|
||||
/**
|
||||
* @zh_CN 是否处于加载处理状态
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* @zh_CN 登录路径
|
||||
*/
|
||||
loginPath?: string;
|
||||
/**
|
||||
* @zh_CN 标题
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
subTitle?: string;
|
||||
/**
|
||||
* @zh_CN 按钮文本
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'RegisterForm',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
formSchema: () => [],
|
||||
loading: false,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
title: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
const [Form, formApi] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
hideRequiredMark: true,
|
||||
},
|
||||
schema: computed(() => props.formSchema),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
const values = await formApi.getValues();
|
||||
if (valid) {
|
||||
emit('submit', values as { password: string; username: string });
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push(props.loginPath);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getFormApi: () => formApi,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Title>
|
||||
<slot name="title">
|
||||
{{ title || $t('authentication.createAnAccount') }} 🚀
|
||||
</slot>
|
||||
<template #desc>
|
||||
<slot name="subTitle">
|
||||
{{ subTitle || $t('authentication.signUpSubtitle') }}
|
||||
</slot>
|
||||
</template>
|
||||
</Title>
|
||||
<Form />
|
||||
|
||||
<VbenButton
|
||||
:class="{
|
||||
'cursor-wait': loading,
|
||||
}"
|
||||
:loading="loading"
|
||||
aria-label="register"
|
||||
class="mt-2 w-full"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<slot name="submitButtonText">
|
||||
{{ submitButtonText || $t('authentication.signUp') }}
|
||||
</slot>
|
||||
</VbenButton>
|
||||
<div class="mt-4 text-center text-sm">
|
||||
{{ $t('authentication.alreadyHaveAccount') }}
|
||||
<span class="vben-link text-sm font-normal" @click="goToLogin()">
|
||||
{{ $t('authentication.goToLogin') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import { MdiGithub, MdiGoogle, MdiQqchat, MdiWechat } from '/@/vben/icons';
|
||||
import { $t } from '/@/vben/locales';
|
||||
|
||||
import { VbenIconButton } from '/@/vben/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThirdPartyLogin',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full sm:mx-auto md:max-w-md">
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="border-input w-[35%] border-b dark:border-gray-600"></span>
|
||||
<span class="text-muted-foreground text-center text-xs uppercase">
|
||||
{{ $t('authentication.thirdPartyLogin') }}
|
||||
</span>
|
||||
<span class="border-input w-[35%] border-b dark:border-gray-600"></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-center">
|
||||
<VbenIconButton class="mb-3">
|
||||
<MdiWechat />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton class="mb-3">
|
||||
<MdiQqchat />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton class="mb-3">
|
||||
<MdiGithub />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton class="mb-3">
|
||||
<MdiGoogle />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue