提交新版本

This commit is contained in:
luob
2025-12-23 17:14:38 +08:00
3632 changed files with 498895 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
{
"name": "@vben/common-ui",
"version": "5.5.9",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/effects/common-ui"
},
"license": "MIT",
"type": "module",
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./es/tippy": {
"types": "./src/components/tippy/index.ts",
"default": "./src/components/tippy/index.ts"
},
"./es/loading": {
"types": "./src/components/loading/index.ts",
"default": "./src/components/loading/index.ts"
}
},
"dependencies": {
"@vben-core/form-ui": "workspace:*",
"@vben-core/popup-ui": "workspace:*",
"@vben-core/preferences": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben/constants": "workspace:*",
"@vben/hooks": "workspace:*",
"@vben/icons": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/types": "workspace:*",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"json-bigint": "catalog:",
"qrcode": "catalog:",
"tippy.js": "catalog:",
"vue": "catalog:",
"vue-json-viewer": "catalog:",
"vue-router": "catalog:",
"vue-tippy": "catalog:"
},
"devDependencies": {
"@types/qrcode": "catalog:"
}
}

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
/**
* Verify 验证码组件
* @description 分发验证码使用
*/
import type { VerificationProps } from './typing';
import { defineAsyncComponent, markRaw, ref, toRefs, watchEffect } from 'vue';
import { IconifyIcon } from '@vben/icons';
import './verify.css';
defineOptions({
name: 'Verification',
});
const props = withDefaults(defineProps<VerificationProps>(), {
arith: 0,
barSize: () => ({
height: '40px',
width: '310px',
}),
blockSize: () => ({
height: '50px',
width: '50px',
}),
captchaType: 'blockPuzzle',
explain: '',
figure: 0,
imgSize: () => ({
height: '155px',
width: '310px',
}),
mode: 'fixed',
space: 5,
});
const emit = defineEmits(['onSuccess', 'onError', 'onClose', 'onReady']);
const VerifyPoints = defineAsyncComponent(() => import('./verify-points.vue'));
const VerifySlide = defineAsyncComponent(() => import('./verify-slide.vue'));
const { captchaType, mode, checkCaptchaApi, getCaptchaApi } = toRefs(props);
const verifyType = ref();
const componentType = ref();
const instance = ref<InstanceType<typeof VerifyPoints | typeof VerifySlide>>();
const showBox = ref(false);
/**
* refresh
* @description 刷新
*/
const refresh = () => {
if (instance.value && instance.value.refresh) instance.value.refresh();
};
const show = () => {
if (mode.value === 'pop') showBox.value = true;
};
const onError = (proxy: any) => {
emit('onError', proxy);
refresh();
};
const onReady = (proxy: any) => {
emit('onReady', proxy);
refresh();
};
const onClose = () => {
emit('onClose');
showBox.value = false;
};
const onSuccess = (data: any) => {
emit('onSuccess', data);
};
watchEffect(() => {
switch (captchaType.value) {
case 'blockPuzzle': {
verifyType.value = '2';
componentType.value = markRaw(VerifySlide);
break;
}
case 'clickWord': {
verifyType.value = '';
componentType.value = markRaw(VerifyPoints);
break;
}
}
});
defineExpose({
onClose,
onError,
onReady,
onSuccess,
show,
refresh,
});
</script>
<template>
<div v-show="showBox">
<div
:class="mode === 'pop' ? 'verifybox' : ''"
:style="{ 'max-width': `${parseInt(imgSize.width) + 20}px` }"
>
<div v-if="mode === 'pop'" class="verifybox-top">
{{ $t('ui.captcha.title') }}
<span class="verifybox-close" @click="onClose">
<IconifyIcon icon="lucide:x" class="size-5" />
</span>
</div>
<div
:style="{ padding: mode === 'pop' ? '10px' : '0' }"
class="verifybox-bottom"
>
<component
:is="componentType"
v-if="componentType"
ref="instance"
:arith="arith"
:bar-size="barSize"
:block-size="blockSize"
:captcha-type="captchaType"
:check-captcha-api="checkCaptchaApi"
:explain="explain"
:figure="figure"
:get-captcha-api="getCaptchaApi"
:img-size="imgSize"
:mode="mode"
:space="space"
:type="verifyType"
@on-close="onClose"
@on-error="onError"
@on-ready="onReady"
@on-success="onSuccess"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,270 @@
<script lang="ts" setup>
import type { ComponentInternalInstance } from 'vue';
import type { VerificationProps } from './typing';
import {
getCurrentInstance,
nextTick,
onMounted,
reactive,
ref,
toRefs,
} from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { AES } from '@vben-core/shared/utils';
import { resetSize } from './utils/util';
/**
* VerifyPoints
* @description 点选
*/
defineOptions({
name: 'VerifyPoints',
});
const props = withDefaults(defineProps<VerificationProps>(), {
barSize: () => ({
height: '40px',
width: '310px',
}),
captchaType: 'clickWord',
imgSize: () => ({
height: '155px',
width: '310px',
}),
mode: 'fixed',
space: 5,
});
const emit = defineEmits(['onSuccess', 'onError', 'onClose', 'onReady']);
const { captchaType, mode, checkCaptchaApi, getCaptchaApi } = toRefs(props);
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const secretKey = ref(); // 后端返回的ase加密秘钥
const checkNum = ref(3); // 默认需要点击的字数
const fontPos = reactive<any[]>([]); // 选中的坐标信息
const checkPosArr = reactive<any[]>([]); // 用户点击的坐标
const num = ref(1); // 点击的记数
const pointBackImgBase = ref(); // 后端获取到的背景图片
const poinTextList = ref<any[]>([]); // 后端返回的点击字体顺序
const backToken = ref(); // 后端返回的token值
const setSize = reactive({
barHeight: 0,
barWidth: 0,
imgHeight: 0,
imgWidth: 0,
});
const tempPoints = reactive<any[]>([]);
const text = ref();
const barAreaColor = ref();
const barAreaBorderColor = ref();
const showRefresh = ref(true);
const bindingClick = ref(true);
function init() {
// 加载页面
fontPos.splice(0);
checkPosArr.splice(0);
num.value = 1;
getPictrue();
nextTick(() => {
const { barHeight, barWidth, imgHeight, imgWidth } = resetSize(proxy);
setSize.imgHeight = imgHeight;
setSize.imgWidth = imgWidth;
setSize.barHeight = barHeight;
setSize.barWidth = barWidth;
emit('onReady', proxy);
});
}
onMounted(() => {
// 禁止拖拽
init();
proxy?.$el?.addEventListener('selectstart', () => {
return false;
});
});
const canvas = ref(null);
// 获取坐标
const getMousePos = function (_obj: any, e: any) {
const x = e.offsetX;
const y = e.offsetY;
return { x, y };
};
// 创建坐标点
const createPoint = function (pos: any) {
tempPoints.push(Object.assign({}, pos));
return num.value + 1;
};
// 坐标转换函数
const pointTransfrom = function (pointArr: any, imgSize: any) {
const newPointArr = pointArr.map((p: any) => {
const x = Math.round((310 * p.x) / Number.parseInt(imgSize.imgWidth));
const y = Math.round((155 * p.y) / Number.parseInt(imgSize.imgHeight));
return { x, y };
});
return newPointArr;
};
const refresh = async function () {
tempPoints.splice(0);
barAreaColor.value = '#000';
barAreaBorderColor.value = '#ddd';
bindingClick.value = true;
fontPos.splice(0);
checkPosArr.splice(0);
num.value = 1;
await getPictrue();
showRefresh.value = true;
};
function canvasClick(e: any) {
checkPosArr.push(getMousePos(canvas, e));
if (num.value === checkNum.value) {
num.value = createPoint(getMousePos(canvas, e));
// 按比例转换坐标值
const arr = pointTransfrom(checkPosArr, setSize);
checkPosArr.length = 0;
checkPosArr.push(...arr);
// 等创建坐标执行完
setTimeout(() => {
// var flag = this.comparePos(this.fontPos, this.checkPosArr);
// 发送后端请求
const captchaVerification = secretKey.value
? AES.encrypt(
`${backToken.value}---${JSON.stringify(checkPosArr)}`,
secretKey.value,
)
: `${backToken.value}---${JSON.stringify(checkPosArr)}`;
const data = {
captchaType: captchaType.value,
pointJson: secretKey.value
? AES.encrypt(JSON.stringify(checkPosArr), secretKey.value)
: JSON.stringify(checkPosArr),
token: backToken.value,
};
checkCaptchaApi?.value?.(data).then((response: any) => {
const res = response.data;
if (res.repCode === '0000') {
barAreaColor.value = '#4cae4c';
barAreaBorderColor.value = '#5cb85c';
text.value = $t('ui.captcha.sliderSuccessText');
bindingClick.value = false;
if (mode.value === 'pop') {
setTimeout(() => {
emit('onClose');
refresh();
}, 1500);
}
emit('onSuccess', { captchaVerification });
} else {
emit('onError', proxy);
barAreaColor.value = '#d9534f';
barAreaBorderColor.value = '#d9534f';
text.value = $t('ui.captcha.sliderRotateFailTip');
setTimeout(() => {
refresh();
}, 700);
}
});
}, 400);
}
if (num.value < checkNum.value)
num.value = createPoint(getMousePos(canvas, e));
}
// 请求背景图片和验证图片
async function getPictrue() {
const data = {
captchaType: captchaType.value,
};
const res = await getCaptchaApi?.value?.(data);
if (res?.data?.repCode === '0000') {
pointBackImgBase.value = `data:image/png;base64,${res?.data?.repData?.originalImageBase64}`;
backToken.value = res.data.repData.token;
secretKey.value = res.data.repData.secretKey;
poinTextList.value = res.data.repData.wordList;
text.value = `${$t('ui.captcha.clickInOrder')}${poinTextList.value.join(',')}`;
} else {
text.value = res?.data?.repMsg;
}
}
defineExpose({
init,
refresh,
});
</script>
<template>
<div style="position: relative">
<div class="verify-img-out">
<div
:style="{
width: setSize.imgWidth,
height: setSize.imgHeight,
'background-size': `${setSize.imgWidth} ${setSize.imgHeight}`,
'margin-bottom': `${space}px`,
}"
class="verify-img-panel"
>
<div
v-show="showRefresh"
class="verify-refresh"
style="z-index: 3"
@click="refresh"
>
<IconifyIcon icon="lucide:refresh-ccw" class="mr-2 size-5" />
</div>
<img
ref="canvas"
:src="pointBackImgBase"
alt=""
style="display: block; width: 100%; height: 100%"
@click="bindingClick ? canvasClick($event) : undefined"
/>
<div
v-for="(tempPoint, index) in tempPoints"
:key="index"
:style="{
'background-color': '#1abd6c',
color: '#fff',
'z-index': 9999,
width: '20px',
height: '20px',
'text-align': 'center',
'line-height': '20px',
'border-radius': '50%',
position: 'absolute',
top: `${tempPoint.y - 10}px`,
left: `${tempPoint.x - 10}px`,
}"
class="point-area"
>
{{ index + 1 }}
</div>
</div>
</div>
<!-- 'height': this.barSize.height, -->
<div
:style="{
width: setSize.imgWidth,
color: barAreaColor,
'border-color': barAreaBorderColor,
'line-height': barSize.height,
}"
class="verify-bar-area"
>
<span class="verify-msg">{{ text }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,378 @@
<script lang="ts" setup>
import type { VerificationProps } from './typing';
/**
* VerifySlide
* @description 滑块
*/
import {
computed,
getCurrentInstance,
nextTick,
onMounted,
reactive,
ref,
toRefs,
} from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { AES } from '@vben-core/shared/utils';
import { resetSize } from './utils/util';
const props = withDefaults(defineProps<VerificationProps>(), {
barSize: () => ({
height: '40px',
width: '310px',
}),
blockSize: () => ({
height: '50px',
width: '50px',
}),
captchaType: 'blockPuzzle',
explain: '',
imgSize: () => ({
height: '155px',
width: '310px',
}),
mode: 'fixed',
type: '1',
space: 5,
});
const emit = defineEmits(['onSuccess', 'onError', 'onClose']);
const {
blockSize,
captchaType,
explain,
mode,
checkCaptchaApi,
getCaptchaApi,
} = toRefs(props);
const { proxy } = getCurrentInstance()!;
const secretKey = ref(); // 后端返回的ase加密秘钥
const passFlag = ref(); // 是否通过的标识
const backImgBase = ref(); // 验证码背景图片
const blockBackImgBase = ref(); // 验证滑块的背景图片
const backToken = ref(); // 后端返回的唯一token值
const startMoveTime = ref(); // 移动开始的时间
const endMovetime = ref(); // 移动结束的时间
const tipWords = ref();
const text = ref();
const finishText = ref();
const setSize = reactive({
barHeight: '0px',
barWidth: '0px',
imgHeight: '0px',
imgWidth: '0px',
});
const moveBlockLeft = ref();
const leftBarWidth = ref();
// 移动中样式
const moveBlockBackgroundColor = ref();
const leftBarBorderColor = ref('#ddd');
const iconColor = ref();
const iconClass = ref('icon-right');
const status = ref(false); // 鼠标状态
const isEnd = ref(false); // 是够验证完成
const showRefresh = ref(true);
const transitionLeft = ref();
const transitionWidth = ref();
const startLeft = ref(0);
const barArea = computed(() => {
return proxy?.$el.querySelector('.verify-bar-area');
});
function init() {
text.value =
explain.value === '' ? $t('ui.captcha.sliderDefaultText') : explain.value;
getPictrue();
nextTick(() => {
const { barHeight, barWidth, imgHeight, imgWidth } = resetSize(proxy);
setSize.imgHeight = imgHeight;
setSize.imgWidth = imgWidth;
setSize.barHeight = barHeight;
setSize.barWidth = barWidth;
proxy?.$parent?.$emit('ready', proxy);
});
window.removeEventListener('touchmove', move);
window.removeEventListener('mousemove', move);
// 鼠标松开
window.removeEventListener('touchend', end);
window.removeEventListener('mouseup', end);
window.addEventListener('touchmove', move);
window.addEventListener('mousemove', move);
// 鼠标松开
window.addEventListener('touchend', end);
window.addEventListener('mouseup', end);
}
onMounted(() => {
// 禁止拖拽
init();
proxy?.$el.addEventListener('selectstart', () => {
return false;
});
});
// 鼠标按下
function start(e: MouseEvent | TouchEvent) {
const x =
((e as TouchEvent).touches
? (e as TouchEvent).touches[0]?.pageX
: (e as MouseEvent).clientX) || 0;
startLeft.value = Math.floor(x - barArea.value.getBoundingClientRect().left);
startMoveTime.value = Date.now(); // 开始滑动的时间
if (isEnd.value === false) {
text.value = '';
moveBlockBackgroundColor.value = '#337ab7';
leftBarBorderColor.value = '#337AB7';
iconColor.value = '#fff';
e.stopPropagation();
status.value = true;
}
}
// 鼠标移动
function move(e: MouseEvent | TouchEvent) {
if (status.value && isEnd.value === false) {
const x =
((e as TouchEvent).touches
? (e as TouchEvent).touches[0]?.pageX
: (e as MouseEvent).clientX) || 0;
const bar_area_left = barArea.value.getBoundingClientRect().left;
let move_block_left = x - bar_area_left; // 小方块相对于父元素的left值
if (
move_block_left >=
barArea.value.offsetWidth - Number.parseInt(blockSize.value.width) / 2 - 2
)
move_block_left =
barArea.value.offsetWidth -
Number.parseInt(blockSize.value.width) / 2 -
2;
if (move_block_left <= 0)
move_block_left = Number.parseInt(blockSize.value.width) / 2;
// 拖动后小方块的left值
moveBlockLeft.value = `${move_block_left - startLeft.value}px`;
leftBarWidth.value = `${move_block_left - startLeft.value}px`;
}
}
// 鼠标松开
function end() {
endMovetime.value = Date.now();
// 判断是否重合
if (status.value && isEnd.value === false) {
let moveLeftDistance = Number.parseInt(
(moveBlockLeft.value || '').replace('px', ''),
);
moveLeftDistance =
(moveLeftDistance * 310) / Number.parseInt(setSize.imgWidth);
const data = {
captchaType: captchaType.value,
pointJson: secretKey.value
? AES.encrypt(
JSON.stringify({ x: moveLeftDistance, y: 5 }),
secretKey.value,
)
: JSON.stringify({ x: moveLeftDistance, y: 5 }),
token: backToken.value,
};
checkCaptchaApi?.value?.(data).then((response) => {
const res = response.data;
if (res.repCode === '0000') {
moveBlockBackgroundColor.value = '#5cb85c';
leftBarBorderColor.value = '#5cb85c';
iconColor.value = '#fff';
iconClass.value = 'icon-check';
showRefresh.value = false;
isEnd.value = true;
if (mode.value === 'pop') {
setTimeout(() => {
emit('onClose');
refresh();
}, 1500);
}
passFlag.value = true;
tipWords.value = `${((endMovetime.value - startMoveTime.value) / 1000).toFixed(2)}s
${$t('ui.captcha.title')}`;
const captchaVerification = secretKey.value
? AES.encrypt(
`${backToken.value}---${JSON.stringify({ x: moveLeftDistance, y: 5 })}`,
secretKey.value,
)
: `${backToken.value}---${JSON.stringify({ x: moveLeftDistance, y: 5 })}`;
setTimeout(() => {
tipWords.value = '';
emit('onSuccess', { captchaVerification });
emit('onClose');
}, 1000);
} else {
moveBlockBackgroundColor.value = '#d9534f';
leftBarBorderColor.value = '#d9534f';
iconColor.value = '#fff';
iconClass.value = 'icon-close';
passFlag.value = false;
setTimeout(() => {
refresh();
}, 1000);
emit('onError', proxy);
tipWords.value = $t('ui.captcha.sliderRotateFailTip');
setTimeout(() => {
tipWords.value = '';
}, 1000);
}
});
status.value = false;
}
}
async function refresh() {
showRefresh.value = true;
finishText.value = '';
transitionLeft.value = 'left .3s';
moveBlockLeft.value = 0;
leftBarWidth.value = undefined;
transitionWidth.value = 'width .3s';
leftBarBorderColor.value = '#ddd';
moveBlockBackgroundColor.value = '#fff';
iconColor.value = '#000';
iconClass.value = 'icon-right';
isEnd.value = false;
await getPictrue();
setTimeout(() => {
transitionWidth.value = '';
transitionLeft.value = '';
text.value = explain.value;
}, 300);
}
// 请求背景图片和验证图片
async function getPictrue() {
const data = {
captchaType: captchaType.value,
};
const res = await getCaptchaApi?.value?.(data);
if (res?.data?.repCode === '0000') {
backImgBase.value = `data:image/png;base64,${res?.data?.repData?.originalImageBase64}`;
blockBackImgBase.value = `data:image/png;base64,${res?.data?.repData?.jigsawImageBase64}`;
backToken.value = res.data.repData.token;
secretKey.value = res.data.repData.secretKey;
} else {
tipWords.value = res?.data?.repMsg;
}
}
defineExpose({
init,
refresh,
});
</script>
<template>
<div style="position: relative">
<div
v-if="type === '2'"
:style="{ height: `${Number.parseInt(setSize.imgHeight) + space}px` }"
class="verify-img-out"
>
<div
:style="{ width: setSize.imgWidth, height: setSize.imgHeight }"
class="verify-img-panel"
>
<img
:src="backImgBase"
alt=""
style="display: block; width: 100%; height: 100%"
/>
<div v-show="showRefresh" class="verify-refresh" @click="refresh">
<IconifyIcon icon="lucide:refresh-ccw" class="mr-2 size-5" />
</div>
<transition name="tips">
<span
v-if="tipWords"
:class="passFlag ? 'suc-bg' : 'err-bg'"
class="verify-tips"
>
{{ tipWords }}
</span>
</transition>
</div>
</div>
<!-- 公共部分 -->
<div
:style="{
width: setSize.imgWidth,
height: barSize.height,
'line-height': barSize.height,
}"
class="verify-bar-area"
>
<span class="verify-msg" v-text="text"></span>
<div
:style="{
width: leftBarWidth !== undefined ? leftBarWidth : barSize.height,
height: barSize.height,
'border-color': leftBarBorderColor,
transition: transitionWidth,
}"
class="verify-left-bar"
>
<span class="verify-msg" v-text="finishText"></span>
<div
:style="{
width: barSize.height,
height: barSize.height,
'background-color': moveBlockBackgroundColor,
left: moveBlockLeft,
transition: transitionLeft,
}"
class="verify-move-block"
@mousedown="start"
@touchstart="start"
>
<i
:class="[iconClass]"
:style="{ color: iconColor }"
class="iconfont verify-icon"
></i>
<div
v-if="type === '2'"
:style="{
width: `${Math.floor((Number.parseInt(setSize.imgWidth) * 47) / 310)}px`,
height: setSize.imgHeight,
top: `-${Number.parseInt(setSize.imgHeight) + space}px`,
'background-size': `${setSize.imgWidth} ${setSize.imgHeight}`,
}"
class="verify-sub-block"
>
<img
:src="blockBackImgBase"
alt=""
style="
display: block;
width: 100%;
height: 100%;
-webkit-user-drag: none;
"
/>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { ComparisonCardProps } from './types';
import { computed } from 'vue';
import {
Card,
CardContent,
VbenCountToAnimator,
VbenIcon,
VbenLoading,
} from '@vben-core/shadcn-ui';
/** 对比卡片 */
defineOptions({ name: 'ComparisonCard' });
const props = defineProps<ComparisonCardProps>();
// TODO @haohao看看能不能用中立的 icon类似 ADD、EDIT 那种。目的:方便后续迁移到 ele 版本里。
const iconMap: Record<string, string> = {
menu: 'ant-design:appstore-outlined',
box: 'ant-design:box-plot-outlined',
cpu: 'ant-design:cluster-outlined',
message: 'ant-design:message-outlined',
};
const iconName = computed(() => iconMap[props.icon] || iconMap.menu);
</script>
<template>
<Card
class="relative h-40 cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<VbenLoading :spinning="loading" />
<CardContent class="flex h-full flex-col p-6">
<div class="mb-4 flex items-start justify-between">
<div class="flex flex-1 flex-col">
<span class="mb-2 text-sm font-medium text-gray-500">
{{ title }}
</span>
<span class="text-3xl font-bold text-gray-800">
<span v-if="value === -1">--</span>
<VbenCountToAnimator v-else :end-val="value" :duration="1000" />
</span>
</div>
<div :class="`text-4xl ${iconColor || ''}`">
<VbenIcon :icon="iconName" />
</div>
</div>
<div class="mt-auto border-t border-gray-100 pt-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">今日新增</span>
<span v-if="todayCount === -1" class="text-gray-400">--</span>
<span v-else class="font-medium text-green-500">
+{{ todayCount }}
</span>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,3 @@
// add by 芋艿:对比卡片,目前 iot 模块在使用
export { default as ComparisonCard } from './comparison-card.vue';
export * from './types';

View File

@@ -0,0 +1,8 @@
export interface ComparisonCardProps {
icon: string; // 图标名称
iconColor?: string; // 图标颜色类名
loading?: boolean; // 加载状态
title: string; // 标题
todayCount: number; // 今日新增数量
value: number; // 数值
}

View File

@@ -0,0 +1,3 @@
// add by 芋艿:统计卡片,目前 mall 模块在使用
export { default as StatisticCard } from './statistic-card.vue';
export * from './types';

View File

@@ -0,0 +1,72 @@
<script lang="ts" setup>
import type { StatisticCardProps } from './types';
import {
Card,
CardContent,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
VbenCountToAnimator,
VbenIcon,
} from '@vben-core/shadcn-ui';
/** 统计卡片 */
defineOptions({ name: 'StatisticCard' });
withDefaults(defineProps<StatisticCardProps>(), {
percentLabel: '环比',
});
</script>
<template>
<Card class="h-full">
<CardContent class="flex flex-col gap-2 p-6">
<div class="text-muted-foreground flex items-center justify-between">
<span class="text-sm">{{ title }}</span>
<TooltipProvider v-if="tooltip">
<Tooltip>
<TooltipTrigger>
<VbenIcon
icon="lucide:circle-alert"
class="text-muted-foreground size-4 cursor-help"
/>
</TooltipTrigger>
<TooltipContent>
<p>{{ tooltip }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="mb-4 text-3xl font-medium">
<VbenCountToAnimator
:prefix="prefix"
:end-val="value ?? 0"
:decimals="decimals ?? 0"
/>
</div>
<div class="flex flex-row gap-1 text-sm">
<span class="text-muted-foreground">{{ percentLabel }}</span>
<span
:class="
Number(percent) > 0
? 'text-destructive'
: 'text-emerald-600 dark:text-emerald-400'
"
class="flex items-center gap-0.5"
>
{{ Math.abs(Number(percent ?? 0)).toFixed(2) }}%
<VbenIcon
:icon="
Number(percent) > 0
? 'lucide:trending-up'
: 'lucide:trending-down'
"
class="size-3"
/>
</span>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,16 @@
export interface StatisticCardProps {
/** 标题 */
title: string;
/** 提示信息 */
tooltip?: string;
/** 前缀 */
prefix?: string;
/** 数值 */
value?: number;
/** 小数位数 */
decimals?: number;
/** 环比百分比 */
percent?: number | string;
/** 环比标签文本 */
percentLabel?: string;
}

View File

@@ -0,0 +1,3 @@
// add by 芋艿:总结卡片,目前 mall 模块在使用
export { default as SummaryCard } from './summary-card.vue';
export * from './types';

View File

@@ -0,0 +1,79 @@
<script lang="ts" setup>
import type { SummaryCardProps } from './types';
import {
Card,
CardContent,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
VbenCountToAnimator,
VbenIcon,
} from '@vben-core/shadcn-ui';
/** 统计卡片 */
defineOptions({ name: 'SummaryCard' });
defineProps<SummaryCardProps>();
</script>
<template>
<Card class="h-full">
<CardContent class="flex items-center gap-3 p-6">
<div
v-if="icon"
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded"
:class="`${iconColor} ${iconBgColor}`"
>
<VbenIcon :icon="icon" class="size-6" />
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1">
<span class="text-muted-foreground text-sm">{{ title }}</span>
<TooltipProvider v-if="tooltip">
<Tooltip>
<TooltipTrigger>
<VbenIcon
icon="lucide:circle-alert"
class="text-muted-foreground size-3"
/>
</TooltipTrigger>
<TooltipContent>
<p>{{ tooltip }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="flex flex-row items-baseline gap-2">
<div class="text-2xl">
<VbenCountToAnimator
:prefix="prefix"
:end-val="value ?? 0"
:decimals="decimals ?? 0"
/>
</div>
<span
v-if="percent !== undefined"
:class="
Number(percent) > 0
? 'text-destructive'
: 'text-emerald-600 dark:text-emerald-400'
"
class="flex items-center"
>
<span class="text-sm">{{ Math.abs(Number(percent)) }}%</span>
<VbenIcon
:icon="
Number(percent) > 0
? 'lucide:chevron-up'
: 'lucide:chevron-down'
"
class="ml-0.5 size-3"
/>
</span>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,22 @@
import type { Component } from 'vue';
export interface SummaryCardProps {
/** 标题 */
title: string;
/** 提示信息 */
tooltip?: string;
/** 图标 */
icon?: Component | string;
/** 图标颜色 */
iconColor?: string;
/** 图标背景色 */
iconBgColor?: string;
/** 前缀 */
prefix?: string;
/** 数值 */
value?: number;
/** 小数位数 */
decimals?: number;
/** 百分比 */
percent?: number | string;
}

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import type { StyleValue } from 'vue';
import type { ContentWrapProps } from './types';
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT } from '@vben-core/shared/constants';
import { cn } from '@vben-core/shared/utils';
defineOptions({
name: 'ContentWrap',
});
const { autoContentHeight = false, heightOffset = 0 } =
defineProps<ContentWrapProps>();
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 - ${footerHeight.value}px - ${typeof heightOffset === 'number' ? `${heightOffset}px` : heightOffset})`,
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="bg-card text-card-foreground border-border relative flex min-h-full flex-col rounded-xl border"
>
<div
v-if="
description ||
$slots.description ||
title ||
$slots.title ||
$slots.extra
"
ref="headerRef"
:class="
cn(
'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>
<div v-if="$slots.extra" class="flex justify-end">
<slot name="extra"></slot>
</div>
</slot>
<slot name="description">
<p v-if="description" class="text-muted-foreground">
{{ description }}
</p>
</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('align-center flex px-6 py-4', footerClass)"
>
<slot name="footer"></slot>
</div>
</div>
</template>

View File

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

View File

@@ -0,0 +1,18 @@
export interface ContentWrapProps {
title?: string;
description?: string;
message?: string;
contentClass?: string;
/**
* 根据content可见高度自适应
*/
autoContentHeight?: boolean;
headerClass?: string;
footerClass?: string;
/**
* Custom height offset value (in pixels) to adjust content area sizing
* when used with autoContentHeight
* @default 0
*/
heightOffset?: number;
}

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import type { VNode } from 'vue';
import { computed, ref, useAttrs, 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-core/shadcn-ui';
import { isFunction } from '@vben-core/shared/utils';
import { objectOmit, 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 attrs = useAttrs();
const modelValue = defineModel({ default: '', type: String });
const visible = ref(false);
const currentSelect = ref('');
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, currentPage } = 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) => {
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',
};
});
function updateCurrentSelect(v: string) {
currentSelect.value = v;
const eventKey = `onUpdate:${props.modelValueProp}`;
if (attrs[eventKey] && isFunction(attrs[eventKey])) {
attrs[eventKey](v);
}
}
const getBindAttrs = computed(() => {
return objectOmit(attrs, [`onUpdate:${props.modelValueProp}`]);
});
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 w-full"
trigger-class="w-full"
>
<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"
:[`onUpdate:${modelValueProp}`]="updateCurrentSelect"
v-bind="getBindAttrs"
>
<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,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
interface IFrameProps {
/** iframe 的源地址 */
src: string;
}
defineProps<IFrameProps>();
const loading = ref(true);
const height = ref('');
const frameRef = ref<HTMLElement | null>(null);
function init() {
height.value = `${document.documentElement.clientHeight - 94.5}px`;
loading.value = false;
}
onMounted(() => {
setTimeout(() => {
init();
}, 300);
});
</script>
<template>
<div v-loading="loading" :style="`height:${height}`">
<iframe
ref="frameRef"
:src="src"
class="h-full w-full"
frameborder="no"
scrolling="auto"
></iframe>
</div>
</template>

View File

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

View File

@@ -0,0 +1,40 @@
export * from './api-component';
export * from './captcha';
export * from './card/comparison-card';
export * from './card/statistic-card';
export * from './card/summary-card';
export * from './col-page';
export * from './content-wrap';
export * from './count-to';
export * from './doc-alert';
export * from './ellipsis-text';
export * from './icon-picker';
export * from './iframe';
export * from './json-viewer';
export * from './loading';
export * from './page';
export * from './resize';
export * from './tippy';
export * from './tree';
export * from '@vben-core/form-ui';
export * from '@vben-core/popup-ui';
// 给文档用
export {
VbenAvatar,
VbenButton,
VbenButtonGroup,
VbenCheckbox,
VbenCheckButtonGroup,
VbenCountToAnimator,
VbenFullScreen,
VbenInputPassword,
VbenLoading,
VbenLogo,
VbenPinInput,
VbenSelect,
VbenSpinner,
} from '@vben-core/shadcn-ui';
export type { FlattenedItem } from '@vben-core/shadcn-ui';
export { globalShareState } from '@vben-core/shared/global-state';

View File

@@ -0,0 +1,112 @@
<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-core/shared/utils';
// @ts-ignore
import JsonBigint from 'json-bigint';
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 = {
el: event.target,
path: pathNode.getAttribute('path') || '',
depth: Number(pathNode.getAttribute('depth')) || 0,
value: event.target.textContent || undefined,
};
param.value = JSON.parse(param.value);
emit('valueClick', param);
}
emit('click', event);
}
// 支持显示 bigint 数据,如较长的订单号
const jsonData = computed<Record<string, any>>(() => {
if (typeof props.value !== 'string') {
return props.value || {};
}
try {
return JsonBigint({ storeAsString: true }).parse(props.value);
} catch (error) {
console.error('JSON parse error:', error);
return {};
}
});
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,
value: jsonData.value,
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;
vertical-align: 2px;
color: hsl(var(--secondary-foreground));
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));
overflow-wrap: 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,121 @@
<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-core/shared/constants';
import { cn } from '@vben-core/shared/utils';
defineOptions({
name: 'Page',
});
const { autoContentHeight = false, heightOffset = 0 } =
defineProps<PageProps>();
const headerHeight = ref(0);
const footerHeight = ref(0);
const docHeight = ref(0);
const shouldAutoHeight = ref(false);
const headerRef = useTemplateRef<HTMLDivElement>('headerRef');
const footerRef = useTemplateRef<HTMLDivElement>('footerRef');
const docRef = useTemplateRef<HTMLDivElement>('docRef');
const contentStyle = computed<StyleValue>(() => {
if (autoContentHeight) {
return {
height: `calc(var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT}) - ${headerHeight.value}px - ${footerHeight.value}px - ${docHeight.value}px - ${typeof heightOffset === 'number' ? `${heightOffset}px` : heightOffset})`,
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;
docHeight.value = docRef.value?.offsetHeight || 0;
setTimeout(() => {
shouldAutoHeight.value = true;
}, 30);
}
function isDocAlertEnable(): boolean {
return import.meta.env.VITE_APP_DOCALERT_ENABLE !== 'false';
}
onMounted(() => {
calcContentHeight();
});
</script>
<template>
<div class="relative flex min-h-full flex-col">
<div
v-if="$slots.doc && isDocAlertEnable()"
ref="docRef"
:class="
cn(
'bg-card border-border relative mx-4 flex items-start rounded-md border-b',
)
"
>
<div class="flex-auto">
<slot name="doc"></slot>
</div>
</div>
<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 flex px-6 py-4', footerClass)"
>
<slot name="footer"></slot>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { TreeProps } from '@vben-core/shadcn-ui';
import { Inbox } from '@vben/icons';
import { $t } from '@vben/locales';
import { treePropsDefaults, VbenTree } from '@vben-core/shadcn-ui';
const props = withDefaults(defineProps<TreeProps>(), treePropsDefaults());
</script>
<template>
<VbenTree v-if="props.treeData?.length > 0" v-bind="props">
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
<slot :name="key" v-bind="slotProps"> </slot>
</template>
</VbenTree>
<div
v-else
class="flex-col-center text-muted-foreground cursor-pointer rounded-lg border p-10 text-sm font-medium"
>
<Inbox class="size-10" />
<div class="mt-1">{{ $t('common.noData') }}</div>
</div>
</template>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '@vben-core/form-ui';
import { computed, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@vben/locales';
import { useVbenForm } from '@vben-core/form-ui';
import { VbenButton } from '@vben-core/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;
/**
* @zh_CN 是否显示返回按钮
*/
showBack?: boolean;
}
defineOptions({
name: 'AuthenticationCodeLogin',
});
const props = withDefaults(defineProps<Props>(), {
loading: false,
showBack: true,
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
v-if="showBack"
class="mt-4 w-full"
variant="outline"
@click="goToLogin()"
>
{{ $t('common.back') }}
</VbenButton>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { SvgDingDingIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { alert, useVbenModal } from '@vben-core/popup-ui';
import { VbenIconButton } from '@vben-core/shadcn-ui';
import { loadScript } from '@vben-core/shared/utils';
interface Props {
clientId: string;
corpId: string;
// 登录回调地址
redirectUri?: string;
// 是否内嵌二维码登录
isQrCode?: boolean;
}
const props = defineProps<Props>();
const route = useRoute();
const [Modal, modalApi] = useVbenModal({
header: false,
footer: false,
fullscreenButton: false,
class: 'w-[302px] h-[302px] dingding-qrcode-login-modal',
onOpened() {
handleQrCodeLogin();
},
});
const getRedirectUri = () => {
const { redirectUri } = props;
if (redirectUri) {
return redirectUri;
}
return window.location.origin + route.fullPath;
};
/**
* 内嵌二维码登录
*/
const handleQrCodeLogin = async () => {
const { clientId, corpId } = props;
if (!(window as any).DTFrameLogin) {
// 二维码登录 加载资源
await loadScript(
'https://g.alicdn.com/dingding/h5-dingtalk-login/0.21.0/ddlogin.js',
);
}
(window as any).DTFrameLogin(
{
id: 'dingding_qrcode_login_element',
width: 300,
height: 300,
},
{
// 注意redirect_uri 需为完整URL扫码后钉钉会带code跳转到这里
redirect_uri: encodeURIComponent(getRedirectUri()),
client_id: clientId,
scope: 'openid corpid',
response_type: 'code',
state: '1',
prompt: 'consent',
corpId,
},
(loginResult: any) => {
const { redirectUrl } = loginResult;
// 这里可以直接进行重定向
window.location.href = redirectUrl;
},
(errorMsg: string) => {
// 这里一般需要展示登录失败的具体原因
alert(`Login Error: ${errorMsg}`);
},
);
};
const handleLogin = () => {
const { clientId, corpId, isQrCode } = props;
if (isQrCode) {
// 内嵌二维码登录
modalApi.open();
} else {
window.location.href = `https://login.dingtalk.com/oauth2/auth?redirect_uri=${encodeURIComponent(getRedirectUri())}&response_type=code&client_id=${clientId}&scope=openid&corpid=${corpId}&prompt=consent`;
}
};
</script>
<template>
<div>
<VbenIconButton
@click="handleLogin"
:tooltip="$t('authentication.dingdingLogin')"
tooltip-side="top"
>
<SvgDingDingIcon />
</VbenIconButton>
<Modal>
<div id="dingding_qrcode_login_element"></div>
</Modal>
</div>
</template>
<style>
.dingding-qrcode-login-modal {
.relative {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '@vben-core/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-core/form-ui';
import { VbenButton, VbenCheckbox } from '@vben-core/shadcn-ui';
import Title from './auth-title.vue';
import DocLink from './doc-link.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>];
thirdLogin: [type: number];
}>();
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);
}
/**
* 处理第三方登录
*
* @param type 第三方平台类型
*/
function handleThirdLogin(type: number) {
emit('thirdLogin', type);
}
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="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"
@third-login="handleThirdLogin"
/>
</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>
<!-- 萌新必读 -->
<DocLink />
</div>
</template>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@vben/locales';
import { VbenButton } from '@vben-core/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;
/**
* @zh_CN 是否显示返回按钮
*/
showBack?: boolean;
}
defineOptions({
name: 'AuthenticationQrCodeLogin',
});
const props = withDefaults(defineProps<Props>(), {
description: '',
loading: false,
showBack: true,
loginPath: '/auth/login',
submitButtonText: '',
subTitle: '',
title: '',
});
const router = useRouter();
// const text = ref('https://vben.vvbin.cn');
const text = ref('https://t.zsxq.com/FUtQd');
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
v-if="showBack"
class="mt-4 w-full"
variant="outline"
@click="goToLogin()"
>
{{ $t('common.back') }}
</VbenButton>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import {
SvgDingDingIcon,
SvgGithubIcon,
SvgQQChatIcon,
SvgWeChatIcon,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { VbenIconButton } from '@vben-core/shadcn-ui';
defineOptions({
name: 'ThirdPartyLogin',
});
const emit = defineEmits<{
thirdLogin: [type: number];
}>();
/**
* 处理第三方登录点击
*
* @param type 第三方平台类型
*/
function handleThirdLogin(type: number) {
emit('thirdLogin', type);
}
</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" @click="handleThirdLogin(30)">
<SvgWeChatIcon />
</VbenIconButton>
<VbenIconButton class="mb-3" @click="handleThirdLogin(20)">
<SvgDingDingIcon />
</VbenIconButton>
<VbenIconButton class="mb-3" @click="handleThirdLogin(0)">
<SvgQQChatIcon />
</VbenIconButton>
<VbenIconButton class="mb-3" @click="handleThirdLogin(0)">
<SvgGithubIcon />
</VbenIconButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { WorkbenchProjectItem } from '../typing';
import {
Card,
CardContent,
CardHeader,
CardTitle,
VbenIcon,
} from '@vben-core/shadcn-ui';
interface Props {
items?: WorkbenchProjectItem[];
title: string;
}
defineOptions({
name: 'WorkbenchProject',
});
withDefaults(defineProps<Props>(), {
items: () => [],
});
defineEmits(['click']);
</script>
<template>
<Card>
<CardHeader class="py-4">
<CardTitle class="text-lg">{{ title }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-wrap p-0">
<template v-for="(item, index) in items" :key="item.title">
<div
:class="{
'border-r-0': index % 3 === 2,
'border-b-0': index < 3,
'pb-4': index > 2,
'rounded-bl-xl': index === items.length - 3,
'rounded-br-xl': index === items.length - 1,
}"
class="border-border group w-full cursor-pointer border-r border-t p-4 transition-all hover:shadow-xl md:w-1/2 lg:w-1/3"
@click="$emit('click', item)"
>
<div class="flex items-center">
<VbenIcon
:color="item.color"
:icon="item.icon"
class="size-8 transition-all duration-300 group-hover:scale-110"
/>
<span class="ml-4 text-lg font-medium">{{ item.title }}</span>
</div>
<div class="text-foreground/80 mt-4 flex h-10">
{{ item.group }}
</div>
<div class="text-foreground/80 flex justify-between">
<span>{{ item.content }}</span>
</div>
</div>
</template>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { WorkbenchTodoItem } from '../typing';
import {
Card,
CardContent,
CardHeader,
CardTitle,
VbenCheckbox,
} from '@vben-core/shadcn-ui';
interface Props {
items?: WorkbenchTodoItem[];
title: string;
}
defineOptions({
name: 'WorkbenchTodo',
});
withDefaults(defineProps<Props>(), {
items: () => [],
});
</script>
<template>
<Card>
<CardHeader class="py-4">
<CardTitle class="text-lg">{{ title }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-wrap p-5 pt-0">
<ul class="divide-border w-full divide-y" role="list">
<li
v-for="item in items"
:key="item.title"
:class="{
'select-none line-through opacity-60': item.completed,
}"
class="flex cursor-pointer justify-between gap-x-6 py-5"
>
<div class="flex min-w-0 items-center gap-x-4">
<VbenCheckbox v-model="item.completed" name="completed" />
<div class="min-w-0 flex-auto">
<p class="text-foreground text-sm font-semibold leading-6">
{{ item.title }}
</p>
<!-- eslint-disable vue/no-v-html -->
<p
class="text-foreground/80 *:text-primary mt-1 truncate text-xs leading-5"
v-html="item.content"
></p>
</div>
</div>
<div class="hidden h-full shrink-0 sm:flex sm:flex-col sm:items-end">
<span class="text-foreground/80 mt-6 text-xs leading-6">
{{ item.date }}
</span>
</div>
</li>
</ul>
</CardContent>
</Card>
</template>

View File

@@ -0,0 +1,5 @@
export * from './about';
export * from './authentication';
export * from './dashboard';
export * from './fallback';
export * from './profile';

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '@vben-core/form-ui';
import { computed, reactive } from 'vue';
import { useVbenForm } from '@vben-core/form-ui';
import { VbenButton } from '@vben-core/shadcn-ui';
interface Props {
formSchema?: VbenFormSchema[];
}
const props = withDefaults(defineProps<Props>(), {
formSchema: () => [],
});
const emit = defineEmits<{
submit: [Recordable<any>];
}>();
const [Form, formApi] = useVbenForm(
reactive({
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: computed(() => props.formSchema),
showDefaultActions: false,
}),
);
async function handleSubmit() {
const { valid } = await formApi.validate();
const values = await formApi.getValues();
if (valid) {
emit('submit', values);
}
}
defineExpose({
getFormApi: () => formApi,
});
</script>
<template>
<div @keydown.enter.prevent="handleSubmit">
<Form />
<VbenButton type="submit" class="mt-4" @click="handleSubmit">
更新基本信息
</VbenButton>
</div>
</template>

View File

@@ -0,0 +1,6 @@
export { default as ProfileBaseSetting } from './base-setting.vue';
export { default as ProfileNotificationSetting } from './notification-setting.vue';
export { default as ProfilePasswordSetting } from './password-setting.vue';
export { default as Profile } from './profile.vue';
export { default as ProfileSecuritySetting } from './security-setting.vue';
export type * from './types';

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { SettingProps } from './types';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
Switch,
} from '@vben-core/shadcn-ui';
withDefaults(defineProps<SettingProps>(), {
formSchema: () => [],
});
const emit = defineEmits<{
change: [Recordable<any>];
}>();
function handleChange(fieldName: string, value: boolean) {
emit('change', { fieldName, value });
}
</script>
<template>
<Form class="space-y-8">
<div class="space-y-4">
<template v-for="item in formSchema" :key="item.fieldName">
<FormField type="checkbox" :name="item.fieldName">
<FormItem
class="flex flex-row items-center justify-between rounded-lg border p-4"
>
<div class="space-y-0.5">
<FormLabel class="text-base"> {{ item.label }} </FormLabel>
<FormDescription>
{{ item.description }}
</FormDescription>
</div>
<FormControl>
<Switch
:model-value="item.value"
@update:model-value="handleChange(item.fieldName, $event)"
/>
</FormControl>
</FormItem>
</FormField>
</template>
</div>
</Form>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { VbenFormSchema } from '@vben-core/form-ui';
import { computed, reactive } from 'vue';
import { useVbenForm } from '@vben-core/form-ui';
import { VbenButton } from '@vben-core/shadcn-ui';
interface Props {
formSchema?: VbenFormSchema[];
}
const props = withDefaults(defineProps<Props>(), {
formSchema: () => [],
});
const emit = defineEmits<{
submit: [Recordable<any>];
}>();
const [Form, formApi] = useVbenForm(
reactive({
commonConfig: {
// 所有表单项
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: computed(() => props.formSchema),
showDefaultActions: false,
}),
);
async function handleSubmit() {
const { valid } = await formApi.validate();
const values = await formApi.getValues();
if (valid) {
emit('submit', values);
}
}
defineExpose({
getFormApi: () => formApi,
});
</script>
<template>
<div>
<Form />
<VbenButton type="submit" class="mt-4" @click="handleSubmit">
更新密码
</VbenButton>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { Props } from './types';
import { preferences } from '@vben-core/preferences';
import {
Card,
Separator,
Tabs,
TabsList,
TabsTrigger,
VbenAvatar,
} from '@vben-core/shadcn-ui';
import { Page } from '../../components';
defineOptions({
name: 'ProfileUI',
});
withDefaults(defineProps<Props>(), {
title: '关于项目',
tabs: () => [],
});
const tabsValue = defineModel<string>('modelValue');
</script>
<template>
<Page auto-content-height>
<div class="flex h-full w-full">
<Card class="w-1/6 flex-none">
<div class="mt-4 flex h-40 flex-col items-center justify-center gap-4">
<VbenAvatar
:src="userInfo?.avatar ?? preferences.app.defaultAvatar"
class="size-20"
/>
<span class="text-lg font-semibold">
{{ userInfo?.nickname ?? '' }}
</span>
<span class="text-foreground/80 text-sm">
{{ userInfo?.username ?? '' }}
</span>
</div>
<Separator class="my-4" />
<Tabs v-model="tabsValue" orientation="vertical" class="m-4">
<TabsList class="bg-card grid w-full grid-cols-1">
<TabsTrigger
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
class="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground h-12 justify-start"
>
{{ tab.label }}
</TabsTrigger>
</TabsList>
</Tabs>
</Card>
<Card class="ml-4 w-5/6 flex-auto p-8">
<slot name="content"></slot>
</Card>
</div>
</Page>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import type { SettingProps } from './types';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
Switch,
} from '@vben-core/shadcn-ui';
withDefaults(defineProps<SettingProps>(), {
formSchema: () => [],
});
const emit = defineEmits<{
change: [Recordable<any>];
}>();
function handleChange(fieldName: string, value: boolean) {
emit('change', { fieldName, value });
}
</script>
<template>
<Form class="space-y-8">
<div class="space-y-4">
<template v-for="item in formSchema" :key="item.fieldName">
<FormField type="checkbox" :name="item.fieldName">
<FormItem
class="flex flex-row items-center justify-between rounded-lg border p-4"
>
<div class="space-y-0.5">
<FormLabel class="text-base"> {{ item.label }} </FormLabel>
<FormDescription>
{{ item.description }}
</FormDescription>
</div>
<FormControl>
<Switch
:model-value="item.value"
@update:model-value="handleChange(item.fieldName, $event)"
/>
</FormControl>
</FormItem>
</FormField>
</template>
</div>
</Form>
</template>

View File

@@ -0,0 +1,21 @@
import type { BasicUserInfo } from '@vben/types';
export interface Props {
title?: string;
userInfo: BasicUserInfo | null;
tabs: {
label: string;
value: string;
}[];
}
export interface FormSchemaItem {
description: string;
fieldName: string;
label: string;
value: boolean;
}
export interface SettingProps {
formSchema: FormSchemaItem[];
}