提交新版本
This commit is contained in:
54
packages/effects/common-ui/package.json
Normal file
54
packages/effects/common-ui/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
// add by 芋艿:对比卡片,目前 iot 模块在使用
|
||||
export { default as ComparisonCard } from './comparison-card.vue';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface ComparisonCardProps {
|
||||
icon: string; // 图标名称
|
||||
iconColor?: string; // 图标颜色类名
|
||||
loading?: boolean; // 加载状态
|
||||
title: string; // 标题
|
||||
todayCount: number; // 今日新增数量
|
||||
value: number; // 数值
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// add by 芋艿:统计卡片,目前 mall 模块在使用
|
||||
export { default as StatisticCard } from './statistic-card.vue';
|
||||
export * from './types';
|
||||
@@ -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>
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface StatisticCardProps {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 提示信息 */
|
||||
tooltip?: string;
|
||||
/** 前缀 */
|
||||
prefix?: string;
|
||||
/** 数值 */
|
||||
value?: number;
|
||||
/** 小数位数 */
|
||||
decimals?: number;
|
||||
/** 环比百分比 */
|
||||
percent?: number | string;
|
||||
/** 环比标签文本 */
|
||||
percentLabel?: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// add by 芋艿:总结卡片,目前 mall 模块在使用
|
||||
export { default as SummaryCard } from './summary-card.vue';
|
||||
export * from './types';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ContentWrap } from './content-wrap.vue';
|
||||
export * from './types';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
37
packages/effects/common-ui/src/components/iframe/iframe.vue
Normal file
37
packages/effects/common-ui/src/components/iframe/iframe.vue
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as IFrame } from './iframe.vue';
|
||||
40
packages/effects/common-ui/src/components/index.ts
Normal file
40
packages/effects/common-ui/src/components/index.ts
Normal 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';
|
||||
112
packages/effects/common-ui/src/components/json-viewer/index.vue
Normal file
112
packages/effects/common-ui/src/components/json-viewer/index.vue
Normal 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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
packages/effects/common-ui/src/components/page/page.vue
Normal file
121
packages/effects/common-ui/src/components/page/page.vue
Normal 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>
|
||||
1120
packages/effects/common-ui/src/components/resize/resize.vue
Normal file
1120
packages/effects/common-ui/src/components/resize/resize.vue
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/effects/common-ui/src/components/tree/index.ts
Normal file
1
packages/effects/common-ui/src/components/tree/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Tree } from './tree.vue';
|
||||
25
packages/effects/common-ui/src/components/tree/tree.vue
Normal file
25
packages/effects/common-ui/src/components/tree/tree.vue
Normal 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>
|
||||
127
packages/effects/common-ui/src/ui/authentication/code-login.vue
Normal file
127
packages/effects/common-ui/src/ui/authentication/code-login.vue
Normal 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>
|
||||
@@ -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>
|
||||
203
packages/effects/common-ui/src/ui/authentication/login.vue
Normal file
203
packages/effects/common-ui/src/ui/authentication/login.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
5
packages/effects/common-ui/src/ui/index.ts
Normal file
5
packages/effects/common-ui/src/ui/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './about';
|
||||
export * from './authentication';
|
||||
export * from './dashboard';
|
||||
export * from './fallback';
|
||||
export * from './profile';
|
||||
56
packages/effects/common-ui/src/ui/profile/base-setting.vue
Normal file
56
packages/effects/common-ui/src/ui/profile/base-setting.vue
Normal 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>
|
||||
6
packages/effects/common-ui/src/ui/profile/index.ts
Normal file
6
packages/effects/common-ui/src/ui/profile/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
62
packages/effects/common-ui/src/ui/profile/profile.vue
Normal file
62
packages/effects/common-ui/src/ui/profile/profile.vue
Normal 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>
|
||||
@@ -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>
|
||||
21
packages/effects/common-ui/src/ui/profile/types.ts
Normal file
21
packages/effects/common-ui/src/ui/profile/types.ts
Normal 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[];
|
||||
}
|
||||
Reference in New Issue
Block a user