feat: 升级前端框架,适配手机端

pull/361/head
xiaojunnuo 2025-03-06 21:11:07 +08:00
commit 8fcabc5e9f
659 changed files with 37406 additions and 873 deletions

View File

@ -9,4 +9,4 @@ VITE_APP_COPYRIGHT_URL=https://certd.handsfree.work
VITE_APP_LOGO=/static/images/logo/logo.svg VITE_APP_LOGO=/static/images/logo/logo.svg
VITE_APP_LOGIN_LOGO=/static/images/logo/rect-black.svg VITE_APP_LOGIN_LOGO=/static/images/logo/rect-black.svg
VITE_APP_PROJECT_PATH=https://github.com/certd/certd VITE_APP_PROJECT_PATH=https://github.com/certd/certd
VITE_APP_NAMESPACE=fs

View File

@ -1,5 +1,5 @@
{ {
"trailingComma": "none", "trailingComma": "none",
"printWidth": 160 "printWidth": 220
} }

View File

@ -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-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示例 * [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
```
# 感谢 # 感谢
### 依赖 ### 依赖

View File

@ -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))`
};
}

View File

@ -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 };

View File

@ -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": {}
}
};

View File

@ -9,7 +9,7 @@
"debug": "vite --mode debug --open", "debug": "vite --mode debug --open",
"debug:pm": "vite --mode debugpm", "debug:pm": "vite --mode debugpm",
"debug:force": "vite --force --mode debug", "debug:force": "vite --force --mode debug",
"build": " vite build ", "build": "vite build ",
"dev-build": "echo 1", "dev-build": "echo 1",
"test:unit": "vitest", "test:unit": "vitest",
"serve": "vite preview", "serve": "vite preview",
@ -21,49 +21,78 @@
"circle:check": "pnpm dependency-cruise --validate --output-type err-html -f dependency-report.html src", "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" "afterPubPush": "git add . && git commit -m \"build: publish success\" && git push"
}, },
"author": "Greper", "author": "greper",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@ant-design/colors": "^7.0.2", "@ant-design/colors": "^7.0.2",
"@ant-design/icons-vue": "^6.1.0", "@ant-design/icons-vue": "^7.0.1",
"@fast-crud/fast-crud": "^1.25.0", "@aws-sdk/client-s3": "^3.535.0",
"@fast-crud/fast-extends": "^1.25.0", "@aws-sdk/s3-request-presigner": "^3.535.0",
"@fast-crud/ui-antdv4": "^1.25.0", "@ctrl/tinycolor": "^4.1.0",
"@fast-crud/ui-interface": "^1.25.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", "@iconify/vue": "^4.1.1",
"@manypkg/get-packages": "^2.2.2",
"@soerenmartius/vue3-clipboard": "^0.1.2", "@soerenmartius/vue3-clipboard": "^0.1.2",
"@vue-js-cron/light": "^4.0.5", "@vue-js-cron/light": "^4.0.5",
"ant-design-vue": "^4.1.2",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"axios": "^1.7.2", "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", "axios-mock-adapter": "^1.22.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"better-scroll": "^2.5.1", "better-scroll": "^2.5.1",
"china-division": "^2.7.0", "china-division": "^2.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"core-js": "^3.36.0", "core-js": "^3.36.0",
"cos-js-sdk-v5": "^1.7.0", "cos-js-sdk-v5": "^1.7.0",
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",
"cropperjs": "^1.6.1", "cropperjs": "^1.6.1",
"cssnano": "^7.0.6",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"defu": "^6.1.4",
"highlight.js": "^11.9.0", "highlight.js": "^11.9.0",
"humanize-duration": "^3.27.3", "humanize-duration": "^3.27.3",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-vue-next": "^0.477.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nanoid": "^4.0.0", "nanoid": "^4.0.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"pinia": "2.1.7", "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", "psl": "^1.9.0",
"qiniu-js": "^3.4.2", "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", "qrcode": "^1.5.4",
"sortablejs": "^1.15.2",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-cropperjs": "^5.0.0", "vue-cropperjs": "^5.0.0",
"vue-echarts": "^7.0.3", "vue-echarts": "^7.0.3",
"vue-i18n": "^9.10.2", "vue-i18n": "^9.10.2",
"vue-router": "^4.3.0", "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": { "devDependencies": {
"@certd/lib-iframe": "^1.30.6", "@certd/lib-iframe": "^1.30.6",
@ -83,7 +112,7 @@
"@vue/compiler-sfc": "^3.4.21", "@vue/compiler-sfc": "^3.4.21",
"@vue/eslint-config-typescript": "^13.0.0", "@vue/eslint-config-typescript": "^13.0.0",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.20",
"caller-path": "^4.0.0", "caller-path": "^4.0.0",
"chai": "^5.1.0", "chai": "^5.1.0",
"dependency-cruiser": "^16.2.3", "dependency-cruiser": "^16.2.3",
@ -105,7 +134,7 @@
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"stylelint": "^15.11.0", "stylelint": "^15.11.0",
"stylelint-order": "^6.0.4", "stylelint-order": "^6.0.4",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.14",
"terser": "^5.29.2", "terser": "^5.29.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tslint": "^6.1.3", "tslint": "^6.1.3",
@ -114,6 +143,7 @@
"vite": "^5.3.1", "vite": "^5.3.1",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2",
"vite-plugin-theme": "^0.8.6",
"vite-plugin-windicss": "^1.9.3", "vite-plugin-windicss": "^1.9.3",
"vitest": "^2.1.2", "vitest": "^2.1.2",
"vue-eslint-parser": "^9.4.2", "vue-eslint-parser": "^9.4.2",

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
// tailwindcss: {},
autoprefixer: {}
}
};

View File

@ -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": {}
}
};

View File

@ -1,21 +1,23 @@
<template> <template>
<a-config-provider :locale="locale" :theme="settingStore.themeToken"> <AConfigProvider :locale="locale" :theme="tokenTheme">
<contextHolder /> <contextHolder />
<fs-form-provider> <fs-form-provider>
<router-view v-if="routerEnabled" /> <router-view />
</fs-form-provider> </fs-form-provider>
</a-config-provider> </AConfigProvider>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import zhCN from "ant-design-vue/es/locale/zh_CN"; import zhCN from "ant-design-vue/es/locale/zh_CN";
import enUS from "ant-design-vue/es/locale/en_US"; import enUS from "ant-design-vue/es/locale/en_US";
import { provide, ref } from "vue"; import { computed, provide, ref } from "vue";
import { usePageStore } from "/src/store/modules/page";
import { useSettingStore } from "/@/store/modules/settings";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import "dayjs/locale/en"; import "dayjs/locale/en";
import dayjs from "dayjs"; 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"; import { Modal } from "ant-design-vue";
defineOptions({ defineOptions({
@ -24,13 +26,12 @@ defineOptions({
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
provide("modal", modal); provide("modal", modal);
// //
const routerEnabled = ref(true);
const locale = ref(zhCN); const locale = ref(zhCN);
async function reload() { async function reload() {}
// routerEnabled.value = false; localeChanged("zh-cn");
// await nextTick(); provide("fn:router.reload", reload);
// routerEnabled.value = true; provide("fn:locale.changed", localeChanged);
} //
function localeChanged(value: any) { function localeChanged(value: any) {
console.log("locale changed:", value); console.log("locale changed:", value);
if (value === "zh-cn") { if (value === "zh-cn") {
@ -45,10 +46,27 @@ localeChanged("zh-cn");
provide("fn:router.reload", reload); provide("fn:router.reload", reload);
provide("fn:locale.changed", localeChanged); 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(); // const resourceStore = useResourceStore();
// resourceStore.init(); // resourceStore.init();
const pageStore = usePageStore(); // const pageStore = usePageStore();
pageStore.init(); // pageStore.init();
const settingStore = useSettingStore(); // const settingStore = useSettingStore();
// settingStore.init();
</script> </script>

View File

@ -25,7 +25,7 @@ export interface UserInfoRes {
id: string | number; id: string | number;
username: string; username: string;
nickName: string; nickName: string;
avatar: string; avatar?: string;
roleIds: number[]; roleIds: number[];
isWeak?: boolean; isWeak?: boolean;
} }

View File

@ -62,5 +62,6 @@ export default defineComponent({
.fs-highlight { .fs-highlight {
margin: 0px; margin: 0px;
border-radius: 4px; border-radius: 4px;
font-size: 12px;
} }
</style> </style>

View File

@ -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>

View File

@ -25,14 +25,14 @@
<script lang="ts"> <script lang="ts">
import i18n from "../../../i18n"; import i18n from "../../../i18n";
import { computed, inject } from "vue"; import { computed, inject } from "vue";
import * as _ from "lodash-es"; import { forEach } from "lodash-es";
export default { export default {
name: "FsLocale", name: "FsLocale",
setup() { setup() {
const languages = computed(() => { const languages = computed(() => {
const map: any = i18n.global.messages?.value || {}; const map: any = i18n.global.messages?.value || {};
const list: any = []; const list: any = [];
_.forEach(map, (item, key) => { forEach(map, (item, key) => {
list.push({ list.push({
key, key,
label: item.label label: item.label

View File

@ -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>

View File

@ -73,6 +73,8 @@ const sysPublic: Ref<SysPublicSetting> = computed(() => {
background-size: 100%; background-size: 100%;
//padding: 50px 0 84px; //padding: 50px 0 84px;
position: relative; position: relative;
display: flex;
flex-direction: column;
.user-layout-content { .user-layout-content {
height: 100%; height: 100%;

View File

@ -1,22 +1,43 @@
import { createApp } from "vue"; import { createApp } from "vue";
import App from "./App.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 "./style/common.less";
import i18n from "./i18n"; import i18n from "./i18n";
import store from "./store";
import components from "./components"; import components from "./components";
import router from "./router";
import plugin from "./plugin/"; import plugin from "./plugin/";
// 正式项目请删除mock避免影响性能 // 正式项目请删除mock避免影响性能
//import "./mock"; //import "./mock";
import { setupVben } from "./vben";
import { util } from "/@/utils";
import { initPreferences } from "/@/vben/preferences";
// @ts-ignore // @ts-ignore
const app = createApp(App); async function bootstrap() {
app.use(Antd); const app = createApp(App);
app.use(router); // app.use(Antd);
app.use(i18n); app.use(Antd);
app.use(store); await setupVben(app);
app.use(components); app.use(router);
app.use(plugin, { i18n }); app.use(i18n);
app.mount("#app"); // 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();

View File

@ -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) { function copyList(originList: any, newList: any, options: any, parentId?: any) {
for (const item of originList) { for (const item of originList) {
const newItem: any = _.cloneDeep(item); const newItem: any = cloneDeep(item);
if (parentId != null && newItem.parentId == null) { if (parentId != null && newItem.parentId == null) {
newItem.parentId = parentId; newItem.parentId = parentId;
} }
@ -218,7 +218,7 @@ const mockUtil: any = {
return { return {
code: 0, code: 0,
msg: "success", msg: "success",
data: _.cloneDeep(req.body) data: cloneDeep(req.body)
}; };
} }
}, },
@ -228,12 +228,12 @@ const mockUtil: any = {
handle(req: any): any { handle(req: any): any {
const item = findById(req.body.id, list); const item = findById(req.body.id, list);
if (item) { if (item) {
_.mergeWith(item, req.body, (objValue: any, srcValue: any) => { mergeWith(item, req.body, (objValue: any, srcValue: any) => {
if (srcValue == null) { if (srcValue == null) {
return; return;
} }
// 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空 // 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空
if (_.isArray(objValue)) { if (isArray(objValue)) {
return srcValue; return srcValue;
} }
}); });
@ -305,12 +305,12 @@ const mockUtil: any = {
console.log("req", req); console.log("req", req);
let item = findById(req.body.id, list); let item = findById(req.body.id, list);
if (item) { 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) { if (srcValue == null) {
return; return;
} }
// 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空 // 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空
if (_.isArray(objValue)) { if (isArray(objValue)) {
return srcValue; return srcValue;
} }
}); });
@ -336,12 +336,12 @@ const mockUtil: any = {
for (const item of req.body) { for (const item of req.body) {
const item2 = findById(item.id, list); const item2 = findById(item.id, list);
if (item2) { if (item2) {
_.mergeWith(item2, item, (objValue: any, srcValue: any) => { mergeWith(item2, item, (objValue: any, srcValue: any) => {
if (srcValue == null) { if (srcValue == null) {
return; return;
} }
// 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空 // 如果被合并对象为数组,则直接被覆盖对象覆盖,只要覆盖对象不为空
if (_.isArray(objValue)) { if (isArray(objValue)) {
return srcValue; return srcValue;
} }
}); });

View File

@ -3,7 +3,7 @@ import cascaderData from "./cascader-data";
import pcaDataLittle from "./pca-data-little"; import pcaDataLittle from "./pca-data-little";
// @ts-ignore // @ts-ignore
import { TreeNodesLazyLoader, getPcaData } from "./pcas-data"; import { TreeNodesLazyLoader, getPcaData } from "./pcas-data";
import * as _ from "lodash-es"; import { cloneDeep } from "lodash-es";
const openStatus = [ const openStatus = [
{ value: "1", label: "打开", color: "success", icon: "ion:radio-button-on" }, { value: "1", label: "打开", color: "success", icon: "ion:radio-button-on" },
{ value: "2", label: "停止", color: "cyan" }, { value: "2", label: "停止", color: "cyan" },
@ -29,7 +29,7 @@ let manyStatus = [
]; ];
let tempManyStatus: any[] = []; let tempManyStatus: any[] = [];
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
tempManyStatus = tempManyStatus.concat(_.cloneDeep(manyStatus)); tempManyStatus = tempManyStatus.concat(cloneDeep(manyStatus));
} }
manyStatus = tempManyStatus; manyStatus = tempManyStatus;
let idIndex = 0; let idIndex = 0;

View File

@ -1,6 +1,6 @@
import { mock } from "../api/service"; import { mock } from "../api/service";
import * as tools from "../api/tools"; import * as tools from "../api/tools";
import * as _ from "lodash-es"; import { forEach } from "lodash-es";
import { utils } from "@fast-crud/fast-crud"; import { utils } from "@fast-crud/fast-crud";
// @ts-ignore // @ts-ignore
const commonMocks: any = import.meta.glob("./common/mock.*.[j|t]s", { eager: true }); 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 viewMocks: any = import.meta.glob("../views/**/mock.[j|t]s", { eager: true });
const list: any = []; const list: any = [];
_.forEach(commonMocks, (value: any) => { forEach(commonMocks, (value: any) => {
list.push(value.default); list.push(value.default);
}); });
_.forEach(apiMocks, (value: any) => { forEach(apiMocks, (value: any) => {
list.push(value.default); list.push(value.default);
}); });
_.forEach(viewMocks, (value: any) => { forEach(viewMocks, (value: any) => {
list.push(value.default); list.push(value.default);
}); });

View File

@ -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"))
);
}
};

View File

@ -29,10 +29,11 @@ import {
import "@fast-crud/fast-extends/dist/style.css"; import "@fast-crud/fast-extends/dist/style.css";
import UiAntdv from "@fast-crud/ui-antdv4"; import UiAntdv from "@fast-crud/ui-antdv4";
import "@fast-crud/ui-antdv4/dist/style.css"; import "@fast-crud/ui-antdv4/dist/style.css";
import * as _ from "lodash-es"; import { merge } from "lodash-es";
import { useCrudPermission } from "../permission"; import { useCrudPermission } from "../permission";
import { App } from "vue"; import { App } from "vue";
import { notification } from "ant-design-vue"; import { notification } from "ant-design-vue";
import { usePreferences } from "/@/vben/preferences";
function install(app: App, options: any = {}) { function install(app: App, options: any = {}) {
app.use(UiAntdv); app.use(UiAntdv);
@ -54,7 +55,18 @@ function install(app: App, options: any = {}) {
commonOptions(props: UseCrudProps): CrudOptions { commonOptions(props: UseCrudProps): CrudOptions {
utils.logger.debug("commonOptions:", props); utils.logger.debug("commonOptions:", props);
const crudBinding = props.crudExpose?.crudBinding; const crudBinding = props.crudExpose?.crudBinding;
const { isMobile } = usePreferences();
const opts: CrudOptions = { const opts: CrudOptions = {
settings: {
plugins: {
mobile: {
enabled: true,
props: {
isMobile: isMobile
}
}
}
},
table: { table: {
scroll: { scroll: {
x: 960 x: 960
@ -90,6 +102,7 @@ function install(app: App, options: any = {}) {
} }
}, },
rowHandle: { rowHandle: {
fixed: "right",
buttons: { buttons: {
view: { type: "link", text: null, icon: "ion:eye-outline" }, view: { type: "link", text: null, icon: "ion:eye-outline" },
copy: { show: true, type: "link", text: null, icon: "ion:copy-outline" }, copy: { show: true, type: "link", text: null, icon: "ion:copy-outline" },
@ -174,6 +187,20 @@ function install(app: App, options: any = {}) {
order: 999999, order: 999999,
width: -1 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", action: "/basic/file/upload",
name: "file", name: "file",
withCredentials: false, 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 // @ts-ignore
const data = new FormData(); const data = new FormData();
data.append("file", file); data.append("file", file);
@ -268,7 +299,7 @@ function install(app: App, options: any = {}) {
// 比如你可以定义一个readonly的公共属性处理该字段只读不能编辑 // 比如你可以定义一个readonly的公共属性处理该字段只读不能编辑
if (columnProps.readonly) { if (columnProps.readonly) {
// 合并column配置 // 合并column配置
_.merge(columnProps, { merge(columnProps, {
form: { show: false }, form: { show: false },
viewForm: { show: true } 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({ registerMergeColumnPlugin({
name: "resize-column-plugin", name: "resize-column-plugin",
order: 2, order: 2,

View File

@ -6,7 +6,7 @@ import { message } from "ant-design-vue";
import NProgress from "nprogress"; import NProgress from "nprogress";
export function registerRouterHook() { export function registerRouterHook() {
// 注册路由beforeEach钩子在第一次加载路由页面时加载权限 // 注册路由beforeEach钩子在第一次加载路由页面时加载权限
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from) => {
const permissionStore = usePermissionStore(); const permissionStore = usePermissionStore();
if (permissionStore.isInited) { if (permissionStore.isInited) {
if (to.meta.permission) { if (to.meta.permission) {
@ -20,15 +20,13 @@ export function registerRouterHook() {
return false; return false;
} }
} }
next(); return true;
return;
} }
const userStore = useUserStore(); const userStore = useUserStore();
const token = userStore.getToken; const token = userStore.getToken;
if (!token || token === "undefined") { if (!token || token === "undefined") {
next(); return true;
return;
} }
// 初始化权限列表 // 初始化权限列表
@ -36,10 +34,10 @@ export function registerRouterHook() {
console.log("permission is enabled"); console.log("permission is enabled");
await permissionStore.loadFromRemote(); await permissionStore.loadFromRemote();
console.log("PM load success"); console.log("PM load success");
next({ ...to, replace: true }); return { ...to, replace: true };
} catch (e) { } catch (e) {
console.error("加载动态路由失败", e); console.error("加载动态路由失败", e);
next(); return false;
} }
}); });
} }

View File

@ -1,8 +1,9 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useResourceStore } from "/src/store/modules/resource"; // import { useResourceStore } from "/src/store/modules/resource";
import { getPermissions } from "./api"; import { getPermissions } from "./api";
import { mitter } from "/@/utils/util.mitt"; import { mitter } from "/@/utils/util.mitt";
import { env } from "/@/utils/util.env"; import { env } from "/@/utils/util.env";
import { useAccessStore } from "/@/vben/stores";
//监听注销事件 //监听注销事件
mitter.on("app.logout", () => { mitter.on("app.logout", () => {
@ -75,8 +76,8 @@ export const usePermissionStore = defineStore({
this.init({ permissions }); this.init({ permissions });
//过滤没有权限的菜单 //过滤没有权限的菜单
const resourceStore = useResourceStore(); const accessStore = useAccessStore();
resourceStore.filterByPermission(permissions); accessStore.setAccessCodes(permissions);
}, },
async loadFromRemote() { async loadFromRemote() {
let permissionTree = []; let permissionTree = [];

View File

@ -1,5 +1,5 @@
import { usePermission } from "/@/plugin/permission"; import { usePermission } from "/@/plugin/permission";
import * as _ from "lodash-es"; import { merge as LodashMerge } from "lodash-es";
export type UseCrudPermissionExtraProps = { export type UseCrudPermissionExtraProps = {
hasActionPermission: (action: string) => boolean; hasActionPermission: (action: string) => boolean;
@ -30,7 +30,7 @@ export function useCrudPermission({ permission }: UseCrudPermissionProps) {
return hasPermissions(prefix + ":" + action); return hasPermissions(prefix + ":" + action);
} }
function buildCrudPermission() { function buildCrudPermission(): any {
if (permission == null) { if (permission == null) {
return {}; return {};
} }
@ -43,7 +43,7 @@ export function useCrudPermission({ permission }: UseCrudPermissionProps) {
} }
} }
return _.merge( return LodashMerge(
{ {
actionbar: { actionbar: {
buttons: { buttons: {
@ -64,7 +64,7 @@ export function useCrudPermission({ permission }: UseCrudPermissionProps) {
function merge(userOptions: any) { function merge(userOptions: any) {
const permissionOptions = buildCrudPermission(); const permissionOptions = buildCrudPermission();
_.merge(permissionOptions, userOptions); LodashMerge(permissionOptions, userOptions);
return permissionOptions; return permissionOptions;
} }

View File

@ -15,6 +15,9 @@ const util = {
const permissionStore = usePermissionStore(); const permissionStore = usePermissionStore();
const userPermissionList = permissionStore.getPermissions; const userPermissionList = permissionStore.getPermissions;
return userPermissionList.some((permission: any) => { return userPermissionList.some((permission: any) => {
if (permission === "*") {
return true;
}
return need.includes(permission); return need.includes(permission);
}); });
}, },

View File

@ -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 };

View File

@ -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 };

View File

@ -1,82 +1,73 @@
import { createRouter, createWebHashHistory } from "vue-router"; import { createRouter, createWebHashHistory } from "vue-router";
// 进度条 // 进度条
import NProgress from "nprogress";
import "nprogress/nprogress.css"; import "nprogress/nprogress.css";
import { usePageStore } from "../store/modules/page";
import { site } from "../utils/util.site";
import { routes } from "./resolve"; import { routes } from "./resolve";
import { useResourceStore } from "../store/modules/resource"; import { createRouterGuard } from "/@/router/guard";
import { useUserStore } from "../store/modules/user";
import { useSettingStore } from "/@/store/modules/settings";
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes routes
}); });
/** /**
*
*/ */
router.beforeEach(async (to, from, next) => { createRouterGuard(router);
// 进度条
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);
}
});
export default 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);

View File

@ -1,5 +1,5 @@
import LayoutPass from "/src/layout/layout-pass.vue"; import LayoutPass from "/src/layout/layout-pass.vue";
import * as _ from "lodash-es"; import { cloneDeep } from "lodash-es";
import { outsideResource } from "./source/outside"; import { outsideResource } from "./source/outside";
import { headerResource } from "./source/header"; import { headerResource } from "./source/header";
import { frameworkResource } from "./source/framework"; import { frameworkResource } from "./source/framework";
@ -19,7 +19,7 @@ function transformOneResource(resource: any, parent: any) {
if (meta.isMenu === false) { if (meta.isMenu === false) {
menu = null; menu = null;
} else { } else {
menu = _.cloneDeep(resource); menu = cloneDeep(resource);
delete menu.component; delete menu.component;
if (menu.path?.startsWith("/")) { if (menu.path?.startsWith("/")) {
menu.fullPath = menu.path; menu.fullPath = menu.path;
@ -28,11 +28,11 @@ function transformOneResource(resource: any, parent: any) {
} }
} }
let route; 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
route = null; route = null;
} else { } else {
route = _.cloneDeep(resource); route = cloneDeep(resource);
if (route.component && typeof route.component === "string") { if (route.component && typeof route.component === "string") {
const path = "/src/views" + route.component; const path = "/src/views" + route.component;
route.component = modules[path]; route.component = modules[path];

View File

@ -1,18 +1,24 @@
import LayoutFramework from "/src/layout/layout-framework.vue"; import LayoutBasic from "/@/layout/layout-basic.vue";
//import { crudResources } from "/@/router/source/modules/crud";
import { sysResources } from "/@/router/source/modules/sys";
import { certdResources } from "/@/router/source/modules/certd";
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 = [ export const frameworkResource = [
{ {
title: "框架", title: "框架",
name: "framework", name: "root",
path: "/", path: "/",
redirect: "/index", redirect: "/index",
component: LayoutFramework, component: LayoutBasic,
meta: { meta: {
icon: "ion:accessibility", icon: "ion:accessibility",
auth: true hideInBreadcrumb: true
}, },
children: [ children: [
{ {
@ -23,12 +29,13 @@ export const frameworkResource = [
meta: { meta: {
fixedAside: true, fixedAside: true,
showOnHeader: false, showOnHeader: false,
icon: "ion:home-outline" icon: "ion:home-outline",
auth: true
} }
}, },
//...crudResources, // @ts-ignore
...certdResources,
...sysResources ...dynamicRoutes
] ]
} }
]; ];

View File

@ -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;

View File

@ -1,4 +1,5 @@
import { useSettingStore } from "/@/store/modules/settings"; import { useSettingStore } from "/@/store/modules/settings";
import aboutResource from "/@/router/source/modules/about";
export const certdResources = [ export const certdResources = [
{ {
@ -8,7 +9,8 @@ export const certdResources = [
redirect: "/certd/pipeline", redirect: "/certd/pipeline",
meta: { meta: {
icon: "ion:key-outline", icon: "ion:key-outline",
auth: true auth: true,
order: 0
}, },
children: [ children: [
{ {
@ -220,3 +222,5 @@ export const certdResources = [
] ]
} }
]; ];
export default certdResources;

View File

@ -1,5 +1,6 @@
import LayoutPass from "/@/layout/layout-pass.vue"; import LayoutPass from "/@/layout/layout-pass.vue";
import { useSettingStore } from "/@/store/modules/settings"; import { useSettingStore } from "/@/store/modules/settings";
import aboutResource from "/@/router/source/modules/about";
export const sysResources = [ export const sysResources = [
{ {
@ -7,10 +8,10 @@ export const sysResources = [
name: "SysRoot", name: "SysRoot",
path: "/sys", path: "/sys",
redirect: "/sys/settings", redirect: "/sys/settings",
component: LayoutPass,
meta: { meta: {
icon: "ion:settings-outline", icon: "ion:settings-outline",
permission: "sys:settings:view" permission: "sys:settings:view",
order: 10
}, },
children: [ children: [
{ {
@ -231,3 +232,5 @@ export const sysResources = [
] ]
} }
]; ];
export default sysResources;

View File

@ -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();
}
}
});

View File

@ -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;
}
}
});

View File

@ -1,5 +1,5 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { Modal, notification, theme } from "ant-design-vue"; import { Modal, notification } from "ant-design-vue";
import * as _ from "lodash-es"; import * as _ from "lodash-es";
// @ts-ignore // @ts-ignore
import { LocalStorage } from "/src/utils/util.storage"; 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 { useUserStore } from "/@/store/modules/user";
import { mitter } from "/@/utils/util.mitt"; import { mitter } from "/@/utils/util.mitt";
import { env } from "/@/utils/util.env"; 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 { export interface SettingState {
themeConfig?: ThemeConfig;
themeToken: ThemeToken;
sysPublic?: SysPublicSetting; sysPublic?: SysPublicSetting;
installInfo?: { installInfo?: {
siteId: string; siteId: string;
@ -40,11 +29,6 @@ export interface SettingState {
suiteSetting?: SuiteSetting; suiteSetting?: SuiteSetting;
} }
const defaultThemeConfig = {
colorPrimary: "#1890ff",
mode: "light"
};
const SETTING_THEME_KEY = "SETTING_THEME";
const defaultSiteInfo: SiteInfo = { const defaultSiteInfo: SiteInfo = {
title: env.TITLE || "Certd", title: env.TITLE || "Certd",
slogan: env.SLOGAN || "让你的证书永不过期", slogan: env.SLOGAN || "让你的证书永不过期",
@ -56,11 +40,6 @@ const defaultSiteInfo: SiteInfo = {
export const useSettingStore = defineStore({ export const useSettingStore = defineStore({
id: "app.setting", id: "app.setting",
state: (): SettingState => ({ state: (): SettingState => ({
themeConfig: null,
themeToken: {
token: {},
algorithm: theme.defaultAlgorithm
},
plusInfo: { plusInfo: {
isPlus: false, isPlus: false,
vipType: "free", vipType: "free",
@ -93,9 +72,6 @@ export const useSettingStore = defineStore({
inited: false inited: false
}), }),
getters: { getters: {
getThemeConfig(): any {
return this.themeConfig || _.merge({}, defaultThemeConfig, LocalStorage.get(SETTING_THEME_KEY) || {});
},
getSysPublic(): SysPublicSetting { getSysPublic(): SysPublicSetting {
return this.sysPublic; return this.sysPublic;
}, },
@ -162,6 +138,10 @@ export const useSettingStore = defineStore({
} }
} }
this.siteInfo = _.merge({}, defaultSiteInfo, siteInfo); this.siteInfo = _.merge({}, defaultSiteInfo, siteInfo);
if (this.siteInfo.logo) {
preferences.logo.source = this.siteInfo.logo;
}
}, },
async checkUrlBound() { async checkUrlBound() {
const userStore = useUserStore(); 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() { async init() {
await this.setThemeConfig(this.getThemeConfig);
await this.loadSysSettings(); await this.loadSysSettings();
}, },
async initOnce() { async initOnce() {

View File

@ -11,6 +11,7 @@ import { message, Modal, notification } from "ant-design-vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { mitter } from "/src/utils/util.mitt"; import { mitter } from "/src/utils/util.mitt";
import { resetAllStores, useAccessStore } from "/@/vben/stores";
interface UserState { interface UserState {
userInfo: Nullable<UserInfoRes>; userInfo: Nullable<UserInfoRes>;
@ -39,8 +40,10 @@ export const useUserStore = defineStore({
} }
}, },
actions: { actions: {
setToken(info: string, expire: number) { setToken(token: string, expire: number) {
this.token = info; this.token = token;
const accessStore = useAccessStore();
accessStore.setAccessToken(token);
LocalStorage.set(TOKEN_KEY, this.token, expire); LocalStorage.set(TOKEN_KEY, this.token, expire);
}, },
setUserInfo(info: UserInfoRes) { setUserInfo(info: UserInfoRes) {
@ -92,11 +95,10 @@ export const useUserStore = defineStore({
}, },
async onLoginSuccess(loginData: any) { async onLoginSuccess(loginData: any) {
await this.getUserInfoAction(); // await this.getUserInfoAction();
const userInfo = await this.getUserInfoAction(); // const userInfo = await this.getUserInfoAction();
mitter.emit("app.login", { userInfo, token: loginData }); mitter.emit("app.login", { token: loginData });
await router.replace("/"); await router.replace("/");
return userInfo;
}, },
/** /**
@ -104,6 +106,7 @@ export const useUserStore = defineStore({
*/ */
logout(goLogin = true) { logout(goLogin = true) {
this.resetState(); this.resetState();
resetAllStores();
goLogin && router.push("/login"); goLogin && router.push("/login");
mitter.emit("app.logout"); mitter.emit("app.logout");
}, },

View File

@ -43,10 +43,20 @@
} }
.ant-modal { //适配手机端
max-width: calc(100% - 32px) !important ; .ant-tour{
max-width: 90vw
} }
.fs-search .ant-row{ .fs-page{
flex-flow: row wrap !important; .fs-page-header{
} background-color: hsl(var(--card));
}
.fs-crud-table{
background-color: hsl(var(--card));
}
}
footer{
background-color: hsl(var(--card)) !important;
}

View File

@ -77,7 +77,6 @@ h1, h2, h3, h4, h5, h6 {
.flex { .flex {
display: flex; display: flex;
align-items: center;
} }
.flex-inline { .flex-inline {
display: inline-flex; display: inline-flex;
@ -107,86 +106,86 @@ h1, h2, h3, h4, h5, h6 {
} }
.m-0{ .m-0{
margin:0 margin:0 !important;
} }
.m-2{ .m-2{
margin:2px margin:2px !important;
} }
.m-3{ .m-3{
margin:3px margin:3px !important;
} }
.m-5{ .m-5{
margin:5px margin:5px !important;
} }
.m-10 { .m-10 {
margin: 10px; margin: 10px !important;;
} }
.m-20{ .m-20{
margin:20px margin:20px !important;
} }
.mb-2 { .mb-2 {
margin-bottom: 2px; margin-bottom: 2px !important;;
} }
.mb-5 { .mb-5 {
margin-bottom: 5px; margin-bottom: 5px !important;;
} }
.ml-5 { .ml-5 {
margin-left: 5px; margin-left: 5px !important;;
} }
.ml-10 { .ml-10 {
margin-left: 10px; margin-left: 10px !important;;
} }
.ml-20 { .ml-20 {
margin-left: 20px; margin-left: 20px !important;;
} }
.ml-15 { .ml-15 {
margin-left: 15px; margin-left: 15px !important;;
} }
.mr-5 { .mr-5 {
margin-right: 5px; margin-right: 5px !important;;
} }
.mr-10 { .mr-10 {
margin-right: 10px; margin-right: 10px !important;;
} }
.mr-20 { .mr-20 {
margin-right: 20px; margin-right: 20px !important;;
} }
.mr-15 { .mr-15 {
margin-right: 15px; margin-right: 15px !important;;
} }
.mt-5 { .mt-5 {
margin-top: 5px; margin-top: 5px !important;;
} }
.mt-10 { .mt-10 {
margin-top: 10px; margin-top: 10px !important;
} }
.mb-10 { .mb-10 {
margin-bottom: 10px; margin-bottom: 10px !important;;
} }
.p-5 { .p-5 {
padding: 5px; padding: 5px !important;;
} }
.p-10 { .p-10 {
padding: 10px; padding: 10px !important;;
} }
.p-20 { .p-20 {
padding: 20px; padding: 20px !important;;
} }
.ellipsis { .ellipsis {
white-space: nowrap; white-space: nowrap;

View File

@ -1,10 +1,10 @@
import * as _ from "lodash-es"; import { isArray } from "lodash-es";
export default { export default {
arrayToMap(array: any) { arrayToMap(array: any) {
if (!array) { if (!array) {
return {}; return {};
} }
if (!_.isArray(array)) { if (!isArray(array)) {
return array; return array;
} }
const map: any = {}; const map: any = {};
@ -19,7 +19,7 @@ export default {
if (!map) { if (!map) {
return []; return [];
} }
if (_.isArray(map)) { if (isArray(map)) {
return map; return map;
} }
const array: any = []; const array: any = [];

View File

@ -1,5 +1,8 @@
// @ts-ignore import {forEach} from "lodash-es";
import * as _ from "lodash-es"; export function getEnvValue(key: string) {
// @ts-ignore
return import.meta.env["VITE_APP_" + key];
}
export class EnvConfig { export class EnvConfig {
MODE: string = import.meta.env.MODE; MODE: string = import.meta.env.MODE;

View File

@ -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>

View File

@ -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. redirectredirect
*/
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 };

View File

@ -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);
}

View File

@ -0,0 +1,4 @@
export { default as AccessControl } from './access-control.vue';
export * from './accessible';
export * from './directive';
export * from './use-access';

View File

@ -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 permissionThe 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 permissionThe 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 };

View File

@ -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>

View File

@ -0,0 +1 @@
export { default as ApiComponent } from './api-component.vue';

View File

@ -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,
};
}

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -0,0 +1,2 @@
export { default as ColPage } from './col-page.vue';
export * from './types';

View File

@ -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;
}

View File

@ -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>

View File

@ -0,0 +1,2 @@
export { default as CountTo } from './count-to.vue';
export * from './types';

View File

@ -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;
}

View File

@ -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>

View File

@ -0,0 +1 @@
export { default as EllipsisText } from './ellipsis-text.vue';

View File

@ -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>

View File

@ -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];
}

View File

@ -0,0 +1 @@
export { default as IconPicker } from './icon-picker.vue';

View File

@ -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';

View File

@ -0,0 +1,3 @@
export { default as JsonViewer } from './index.vue';
export * from './types';

View File

@ -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>

View File

@ -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));
}
}
}
}
}

View File

@ -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;
}

View File

@ -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,
);
}
}

View File

@ -0,0 +1,3 @@
export * from './directive';
export { default as Loading } from './loading.vue';
export { default as Spinner } from './spinner.vue';

View File

@ -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>

View File

@ -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>

View File

@ -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');
});
});

View File

@ -0,0 +1,2 @@
export { default as Page } from './page.vue';
export * from './types';

View File

@ -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>

View File

@ -0,0 +1,11 @@
export interface PageProps {
title?: string;
description?: string;
contentClass?: string;
/**
* content
*/
autoContentHeight?: boolean;
headerClass?: string;
footerClass?: string;
}

View File

@ -0,0 +1 @@
export { default as VResize } from './resize.vue';

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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,
);
};

View File

@ -0,0 +1,2 @@
export * from './components';
export * from './ui';

View File

@ -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 };

View File

@ -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>

View File

@ -0,0 +1 @@
export { default as About } from './about.vue';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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