feat: 初始化项目基础结构和配置
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { AnyPromiseFunction } from '@vben/types';
|
||||
|
||||
import { computed, nextTick, ref, unref, useAttrs, watch } from 'vue';
|
||||
|
||||
import { LoaderCircle } from '@vben/icons';
|
||||
|
||||
import { cloneDeep, get, isEqual, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { objectOmit } from '@vueuse/core';
|
||||
|
||||
type OptionsItem = {
|
||||
[name: string]: any;
|
||||
children?: OptionsItem[];
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** 组件 */
|
||||
component: Component;
|
||||
/** 是否将value从数字转为string */
|
||||
numberToString?: boolean;
|
||||
/** 获取options数据的函数 */
|
||||
api?: (arg?: any) => Promise<OptionsItem[] | Record<string, any>>;
|
||||
/** 传递给api的参数 */
|
||||
params?: Record<string, any>;
|
||||
/** 从api返回的结果中提取options数组的字段名 */
|
||||
resultField?: string;
|
||||
/** label字段名 */
|
||||
labelField?: string;
|
||||
/** children字段名,需要层级数据的组件可用 */
|
||||
childrenField?: string;
|
||||
/** value字段名 */
|
||||
valueField?: string;
|
||||
/** disabled字段名 */
|
||||
disabledField?: string;
|
||||
/** 组件接收options数据的属性名 */
|
||||
optionsPropName?: string;
|
||||
/** 是否立即调用api */
|
||||
immediate?: boolean;
|
||||
/** 每次`visibleEvent`事件发生时都重新请求数据 */
|
||||
alwaysLoad?: boolean;
|
||||
/** 在api请求之前的回调函数 */
|
||||
beforeFetch?: AnyPromiseFunction<any, any>;
|
||||
/** 在api请求之后的回调函数 */
|
||||
afterFetch?: AnyPromiseFunction<any, any>;
|
||||
/** 直接传入选项数据,也作为api返回空数据时的后备数据 */
|
||||
options?: OptionsItem[];
|
||||
/** 组件的插槽名称,用来显示一个"加载中"的图标 */
|
||||
loadingSlot?: string;
|
||||
/** 触发api请求的事件名 */
|
||||
visibleEvent?: string;
|
||||
/** 组件的v-model属性名,默认为modelValue。部分组件可能为value */
|
||||
modelPropName?: string;
|
||||
/**
|
||||
* 自动选择
|
||||
* - `first`:自动选择第一个选项
|
||||
* - `last`:自动选择最后一个选项
|
||||
* - `one`: 当请求的结果只有一个选项时,自动选择该选项
|
||||
* - 函数:自定义选择逻辑,函数的参数为请求的结果数组,返回值为选择的选项
|
||||
* - false:不自动选择(默认)
|
||||
*/
|
||||
autoSelect?:
|
||||
| 'first'
|
||||
| 'last'
|
||||
| 'one'
|
||||
| ((item: OptionsItem[]) => OptionsItem)
|
||||
| false;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'ApiComponent', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
labelField: 'label',
|
||||
valueField: 'value',
|
||||
disabledField: 'disabled',
|
||||
childrenField: '',
|
||||
optionsPropName: 'options',
|
||||
resultField: '',
|
||||
visibleEvent: '',
|
||||
numberToString: false,
|
||||
params: () => ({}),
|
||||
immediate: true,
|
||||
alwaysLoad: false,
|
||||
loadingSlot: '',
|
||||
beforeFetch: undefined,
|
||||
afterFetch: undefined,
|
||||
modelPropName: 'modelValue',
|
||||
api: undefined,
|
||||
autoSelect: false,
|
||||
options: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
optionsChange: [OptionsItem[]];
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<any>({ default: undefined });
|
||||
|
||||
const attrs = useAttrs();
|
||||
const innerParams = ref({});
|
||||
const refOptions = ref<OptionsItem[]>([]);
|
||||
const loading = ref(false);
|
||||
// 首次是否加载过了
|
||||
const isFirstLoaded = ref(false);
|
||||
// 标记是否有待处理的请求
|
||||
const hasPendingRequest = ref(false);
|
||||
|
||||
const getOptions = computed(() => {
|
||||
const {
|
||||
labelField,
|
||||
valueField,
|
||||
disabledField,
|
||||
childrenField,
|
||||
numberToString,
|
||||
} = props;
|
||||
|
||||
const refOptionsData = unref(refOptions);
|
||||
|
||||
function transformData(data: OptionsItem[]): OptionsItem[] {
|
||||
return data.map((item) => {
|
||||
const value = get(item, valueField);
|
||||
const disabled = get(item, disabledField);
|
||||
return {
|
||||
...objectOmit(item, [labelField, valueField, disabled, childrenField]),
|
||||
label: get(item, labelField),
|
||||
value: numberToString ? `${value}` : value,
|
||||
disabled: get(item, disabledField),
|
||||
...(childrenField && item[childrenField]
|
||||
? { children: transformData(item[childrenField]) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const data: OptionsItem[] = transformData(refOptionsData);
|
||||
|
||||
return data.length > 0 ? data : props.options;
|
||||
});
|
||||
|
||||
const bindProps = computed(() => {
|
||||
return {
|
||||
[props.modelPropName]: unref(modelValue),
|
||||
[props.optionsPropName]: unref(getOptions),
|
||||
[`onUpdate:${props.modelPropName}`]: (val: string) => {
|
||||
modelValue.value = val;
|
||||
},
|
||||
...objectOmit(attrs, [`onUpdate:${props.modelPropName}`]),
|
||||
...(props.visibleEvent
|
||||
? {
|
||||
[props.visibleEvent]: handleFetchForVisible,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
async function fetchApi() {
|
||||
const { api, beforeFetch, afterFetch, resultField } = props;
|
||||
|
||||
if (!api || !isFunction(api)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果正在加载,标记有待处理的请求并返回
|
||||
if (loading.value) {
|
||||
hasPendingRequest.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
refOptions.value = [];
|
||||
try {
|
||||
loading.value = true;
|
||||
let finalParams = unref(mergedParams);
|
||||
if (beforeFetch && isFunction(beforeFetch)) {
|
||||
finalParams = (await beforeFetch(cloneDeep(finalParams))) || finalParams;
|
||||
}
|
||||
let res = await api(finalParams);
|
||||
if (afterFetch && isFunction(afterFetch)) {
|
||||
res = (await afterFetch(res)) || res;
|
||||
}
|
||||
isFirstLoaded.value = true;
|
||||
if (Array.isArray(res)) {
|
||||
refOptions.value = res;
|
||||
emitChange();
|
||||
return;
|
||||
}
|
||||
if (resultField) {
|
||||
refOptions.value = get(res, resultField) || [];
|
||||
}
|
||||
emitChange();
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
// reset status
|
||||
isFirstLoaded.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// 如果有待处理的请求,立即触发新的请求
|
||||
if (hasPendingRequest.value) {
|
||||
hasPendingRequest.value = false;
|
||||
// 使用 nextTick 确保状态更新完成后再触发新请求
|
||||
await nextTick();
|
||||
fetchApi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchForVisible(visible: boolean) {
|
||||
if (visible) {
|
||||
if (props.alwaysLoad) {
|
||||
await fetchApi();
|
||||
} else if (!props.immediate && !unref(isFirstLoaded)) {
|
||||
await fetchApi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mergedParams = computed(() => {
|
||||
return {
|
||||
...props.params,
|
||||
...unref(innerParams),
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
mergedParams,
|
||||
(value, oldValue) => {
|
||||
if (isEqual(value, oldValue)) {
|
||||
return;
|
||||
}
|
||||
fetchApi();
|
||||
},
|
||||
{ deep: true, immediate: props.immediate },
|
||||
);
|
||||
|
||||
function emitChange() {
|
||||
if (
|
||||
modelValue.value === undefined &&
|
||||
props.autoSelect &&
|
||||
unref(getOptions).length > 0
|
||||
) {
|
||||
let firstOption;
|
||||
if (isFunction(props.autoSelect)) {
|
||||
firstOption = props.autoSelect(unref(getOptions));
|
||||
} else {
|
||||
switch (props.autoSelect) {
|
||||
case 'first': {
|
||||
firstOption = unref(getOptions)[0];
|
||||
break;
|
||||
}
|
||||
case 'last': {
|
||||
firstOption = unref(getOptions)[unref(getOptions).length - 1];
|
||||
break;
|
||||
}
|
||||
case 'one': {
|
||||
if (unref(getOptions).length === 1) {
|
||||
firstOption = unref(getOptions)[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstOption) modelValue.value = firstOption.value;
|
||||
}
|
||||
emit('optionsChange', unref(getOptions));
|
||||
}
|
||||
const componentRef = ref();
|
||||
defineExpose({
|
||||
/** 获取options数据 */
|
||||
getOptions: () => unref(getOptions),
|
||||
/** 获取当前值 */
|
||||
getValue: () => unref(modelValue),
|
||||
/** 获取被包装的组件实例 */
|
||||
getComponentRef: <T = any,>() => componentRef.value as T,
|
||||
/** 更新Api参数 */
|
||||
updateParam(newParams: Record<string, any>) {
|
||||
innerParams.value = newParams;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<component
|
||||
:is="component"
|
||||
v-bind="bindProps"
|
||||
:placeholder="$attrs.placeholder"
|
||||
ref="componentRef"
|
||||
>
|
||||
<template v-for="item in Object.keys($slots)" #[item]="data">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
<template v-if="loadingSlot && loading" #[loadingSlot]>
|
||||
<LoaderCircle class="animate-spin" />
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ApiComponent } from './api-component.vue';
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { CaptchaPoint } from '../types';
|
||||
|
||||
import { reactive } from 'vue';
|
||||
|
||||
export function useCaptchaPoints() {
|
||||
const points = reactive<CaptchaPoint[]>([]);
|
||||
function addPoint(point: CaptchaPoint) {
|
||||
points.push(point);
|
||||
}
|
||||
|
||||
function clearPoints() {
|
||||
points.splice(0);
|
||||
}
|
||||
return {
|
||||
addPoint,
|
||||
clearPoints,
|
||||
points,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as PointSelectionCaptcha } from './point-selection-captcha/index.vue';
|
||||
export { default as PointSelectionCaptchaCard } from './point-selection-captcha/index.vue';
|
||||
|
||||
export { default as SliderCaptcha } from './slider-captcha/index.vue';
|
||||
export { default as SliderRotateCaptcha } from './slider-rotate-captcha/index.vue';
|
||||
export { default as SliderTranslateCaptcha } from './slider-translate-captcha/index.vue';
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import type { CaptchaPoint, PointSelectionCaptchaProps } from '../types';
|
||||
|
||||
import { RotateCw } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { VbenButton, VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useCaptchaPoints } from '../hooks/useCaptchaPoints';
|
||||
import CaptchaCard from './point-selection-captcha-card.vue';
|
||||
|
||||
const props = withDefaults(defineProps<PointSelectionCaptchaProps>(), {
|
||||
height: '220px',
|
||||
hintImage: '',
|
||||
hintText: '',
|
||||
paddingX: '12px',
|
||||
paddingY: '16px',
|
||||
showConfirm: false,
|
||||
title: '',
|
||||
width: '300px',
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
click: [CaptchaPoint];
|
||||
confirm: [Array<CaptchaPoint>, clear: () => void];
|
||||
refresh: [];
|
||||
}>();
|
||||
const { addPoint, clearPoints, points } = useCaptchaPoints();
|
||||
|
||||
if (!props.hintImage && !props.hintText) {
|
||||
console.warn('At least one of hint image or hint text must be provided');
|
||||
}
|
||||
|
||||
const POINT_OFFSET = 11;
|
||||
|
||||
function getElementPosition(element: HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + window.scrollX,
|
||||
y: rect.top + window.scrollY,
|
||||
};
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
try {
|
||||
const dom = e.currentTarget as HTMLElement;
|
||||
if (!dom) throw new Error('Element not found');
|
||||
|
||||
const { x: domX, y: domY } = getElementPosition(dom);
|
||||
|
||||
const mouseX = e.clientX + window.scrollX;
|
||||
const mouseY = e.clientY + window.scrollY;
|
||||
|
||||
if (typeof mouseX !== 'number' || typeof mouseY !== 'number') {
|
||||
throw new TypeError('Mouse coordinates not found');
|
||||
}
|
||||
|
||||
const xPos = mouseX - domX;
|
||||
const yPos = mouseY - domY;
|
||||
|
||||
const rect = dom.getBoundingClientRect();
|
||||
|
||||
// 点击位置边界校验
|
||||
if (xPos < 0 || yPos < 0 || xPos > rect.width || yPos > rect.height) {
|
||||
console.warn('Click position is out of the valid range');
|
||||
return;
|
||||
}
|
||||
|
||||
const x = Math.ceil(xPos);
|
||||
const y = Math.ceil(yPos);
|
||||
|
||||
const point = {
|
||||
i: points.length,
|
||||
t: Date.now(),
|
||||
x,
|
||||
y,
|
||||
};
|
||||
|
||||
addPoint(point);
|
||||
|
||||
emit('click', point);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
} catch (error) {
|
||||
console.error('Error in handleClick:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try {
|
||||
clearPoints();
|
||||
} catch (error) {
|
||||
console.error('Error in clear:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
try {
|
||||
clear();
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
console.error('Error in handleRefresh:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!props.showConfirm) return;
|
||||
try {
|
||||
emit('confirm', points, clear);
|
||||
} catch (error) {
|
||||
console.error('Error in handleConfirm:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<CaptchaCard
|
||||
:captcha-image="captchaImage"
|
||||
:height="height"
|
||||
:padding-x="paddingX"
|
||||
:padding-y="paddingY"
|
||||
:title="title"
|
||||
:width="width"
|
||||
@click="handleClick"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title">{{ $t('ui.captcha.title') }}</slot>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<VbenIconButton
|
||||
:aria-label="$t('ui.captcha.refreshAriaLabel')"
|
||||
class="ml-1"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<RotateCw class="size-5" />
|
||||
</VbenIconButton>
|
||||
<VbenButton
|
||||
v-if="showConfirm"
|
||||
:aria-label="$t('ui.captcha.confirmAriaLabel')"
|
||||
class="ml-2"
|
||||
size="sm"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ $t('ui.captcha.confirm') }}
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-for="(point, index) in points"
|
||||
:key="index"
|
||||
:aria-label="$t('ui.captcha.pointAriaLabel') + (index + 1)"
|
||||
:style="{
|
||||
top: `${point.y - POINT_OFFSET}px`,
|
||||
left: `${point.x - POINT_OFFSET}px`,
|
||||
}"
|
||||
class="bg-primary text-primary-50 border-primary-50 absolute z-20 flex h-5 w-5 cursor-default items-center justify-center rounded-full border-2"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<template #footer>
|
||||
<img
|
||||
v-if="hintImage"
|
||||
:alt="$t('ui.captcha.alt')"
|
||||
:src="hintImage"
|
||||
class="border-border h-10 w-full rounded border"
|
||||
/>
|
||||
<div
|
||||
v-else-if="hintText"
|
||||
class="border-border flex-center h-10 w-full rounded border"
|
||||
>
|
||||
{{ `${$t('ui.captcha.clickInOrder')}` + `【${hintText}】` }}
|
||||
</div>
|
||||
</template>
|
||||
</CaptchaCard>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import type { PointSelectionCaptchaCardProps } from '../types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
const props = withDefaults(defineProps<PointSelectionCaptchaCardProps>(), {
|
||||
height: '220px',
|
||||
paddingX: '12px',
|
||||
paddingY: '16px',
|
||||
title: '',
|
||||
width: '300px',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [MouseEvent];
|
||||
}>();
|
||||
|
||||
const parseValue = (value: number | string) => {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
const rootStyles = computed(() => ({
|
||||
padding: `${parseValue(props.paddingY)}px ${parseValue(props.paddingX)}px`,
|
||||
width: `${parseValue(props.width) + parseValue(props.paddingX) * 2}px`,
|
||||
}));
|
||||
|
||||
const captchaStyles = computed(() => {
|
||||
return {
|
||||
height: `${parseValue(props.height)}px`,
|
||||
width: `${parseValue(props.width)}px`,
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
emit('click', e);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Card :style="rootStyles" aria-labelledby="captcha-title" role="region">
|
||||
<CardHeader class="p-0">
|
||||
<CardTitle id="captcha-title" class="flex items-center justify-between">
|
||||
<template v-if="$slots.title">
|
||||
<slot name="title">{{ $t('ui.captcha.title') }}</slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ title }}</span>
|
||||
</template>
|
||||
<div class="flex items-center justify-end">
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0">
|
||||
<img
|
||||
v-show="captchaImage"
|
||||
:alt="$t('ui.captcha.alt')"
|
||||
:src="captchaImage"
|
||||
:style="captchaStyles"
|
||||
class="relative z-10"
|
||||
@click="handleClick"
|
||||
/>
|
||||
<div class="absolute inset-0">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="mt-2 flex justify-between p-0">
|
||||
<slot name="footer"></slot>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
CaptchaVerifyPassingData,
|
||||
SliderCaptchaProps,
|
||||
SliderRotateVerifyPassingData,
|
||||
} from '../types';
|
||||
|
||||
import { reactive, unref, useTemplateRef, watch, watchEffect } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
|
||||
import SliderCaptchaAction from './slider-captcha-action.vue';
|
||||
import SliderCaptchaBar from './slider-captcha-bar.vue';
|
||||
import SliderCaptchaContent from './slider-captcha-content.vue';
|
||||
|
||||
const props = withDefaults(defineProps<SliderCaptchaProps>(), {
|
||||
actionStyle: () => ({}),
|
||||
barStyle: () => ({}),
|
||||
contentStyle: () => ({}),
|
||||
isSlot: false,
|
||||
successText: '',
|
||||
text: '',
|
||||
wrapperStyle: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
end: [MouseEvent | TouchEvent];
|
||||
move: [SliderRotateVerifyPassingData];
|
||||
start: [MouseEvent | TouchEvent];
|
||||
success: [CaptchaVerifyPassingData];
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<boolean>({ default: false });
|
||||
|
||||
const state = reactive({
|
||||
endTime: 0,
|
||||
isMoving: false,
|
||||
isPassing: false,
|
||||
moveDistance: 0,
|
||||
startTime: 0,
|
||||
toLeft: false,
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
resume,
|
||||
});
|
||||
|
||||
const wrapperRef = useTemplateRef<HTMLDivElement>('wrapperRef');
|
||||
const barRef = useTemplateRef<typeof SliderCaptchaBar>('barRef');
|
||||
const contentRef = useTemplateRef<typeof SliderCaptchaContent>('contentRef');
|
||||
const actionRef = useTemplateRef<typeof SliderCaptchaAction>('actionRef');
|
||||
|
||||
watch(
|
||||
() => state.isPassing,
|
||||
(isPassing) => {
|
||||
if (isPassing) {
|
||||
const { endTime, startTime } = state;
|
||||
const time = (endTime - startTime) / 1000;
|
||||
emit('success', { isPassing, time: time.toFixed(1) });
|
||||
modelValue.value = isPassing;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
state.isPassing = !!modelValue.value;
|
||||
});
|
||||
|
||||
function getEventPageX(e: MouseEvent | TouchEvent): number {
|
||||
if ('pageX' in e) {
|
||||
return e.pageX;
|
||||
} else if ('touches' in e && e.touches[0]) {
|
||||
return e.touches[0].pageX;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function handleDragStart(e: MouseEvent | TouchEvent) {
|
||||
if (state.isPassing) {
|
||||
return;
|
||||
}
|
||||
if (!actionRef.value) return;
|
||||
emit('start', e);
|
||||
|
||||
state.moveDistance =
|
||||
getEventPageX(e) -
|
||||
Number.parseInt(
|
||||
actionRef.value.getStyle().left.replace('px', '') || '0',
|
||||
10,
|
||||
);
|
||||
state.startTime = Date.now();
|
||||
state.isMoving = true;
|
||||
}
|
||||
|
||||
function getOffset(actionEl: HTMLDivElement) {
|
||||
const wrapperWidth = wrapperRef.value?.offsetWidth ?? 220;
|
||||
const actionWidth = actionEl?.offsetWidth ?? 40;
|
||||
const offset = wrapperWidth - actionWidth - 6;
|
||||
return { actionWidth, offset, wrapperWidth };
|
||||
}
|
||||
|
||||
function handleDragMoving(e: MouseEvent | TouchEvent) {
|
||||
const { isMoving, moveDistance } = state;
|
||||
if (isMoving) {
|
||||
const actionEl = unref(actionRef);
|
||||
const barEl = unref(barRef);
|
||||
if (!actionEl || !barEl) return;
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
|
||||
const moveX = getEventPageX(e) - moveDistance;
|
||||
|
||||
emit('move', {
|
||||
event: e,
|
||||
moveDistance,
|
||||
moveX,
|
||||
});
|
||||
if (moveX > 0 && moveX <= offset) {
|
||||
actionEl.setLeft(`${moveX}px`);
|
||||
barEl.setWidth(`${moveX + actionWidth / 2}px`);
|
||||
} else if (moveX > offset) {
|
||||
actionEl.setLeft(`${wrapperWidth - actionWidth}px`);
|
||||
barEl.setWidth(`${wrapperWidth - actionWidth / 2}px`);
|
||||
if (!props.isSlot) {
|
||||
checkPass();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: MouseEvent | TouchEvent) {
|
||||
const { isMoving, isPassing, moveDistance } = state;
|
||||
if (isMoving && !isPassing) {
|
||||
emit('end', e);
|
||||
const actionEl = actionRef.value;
|
||||
const barEl = unref(barRef);
|
||||
if (!actionEl || !barEl) return;
|
||||
const moveX = getEventPageX(e) - moveDistance;
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
|
||||
if (moveX < offset) {
|
||||
if (props.isSlot) {
|
||||
setTimeout(() => {
|
||||
if (modelValue.value) {
|
||||
const contentEl = unref(contentRef);
|
||||
if (contentEl) {
|
||||
contentEl.getEl().style.width = `${Number.parseInt(barEl.getEl().style.width)}px`;
|
||||
}
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
} else {
|
||||
actionEl.setLeft(`${wrapperWidth - actionWidth}px`);
|
||||
barEl.setWidth(`${wrapperWidth - actionWidth / 2}px`);
|
||||
checkPass();
|
||||
}
|
||||
state.isMoving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkPass() {
|
||||
if (props.isSlot) {
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
state.endTime = Date.now();
|
||||
state.isPassing = true;
|
||||
state.isMoving = false;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
state.isMoving = false;
|
||||
state.isPassing = false;
|
||||
state.moveDistance = 0;
|
||||
state.toLeft = false;
|
||||
state.startTime = 0;
|
||||
state.endTime = 0;
|
||||
const actionEl = unref(actionRef);
|
||||
const barEl = unref(barRef);
|
||||
const contentEl = unref(contentRef);
|
||||
if (!actionEl || !barEl || !contentEl) return;
|
||||
|
||||
contentEl.getEl().style.width = '100%';
|
||||
state.toLeft = true;
|
||||
useTimeoutFn(() => {
|
||||
state.toLeft = false;
|
||||
actionEl.setLeft('0');
|
||||
barEl.setWidth('0');
|
||||
}, 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn(
|
||||
'border-border bg-background-deep relative flex h-10 w-full items-center overflow-hidden rounded-md border text-center',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
:style="wrapperStyle"
|
||||
@mouseleave="handleDragOver"
|
||||
@mousemove="handleDragMoving"
|
||||
@mouseup="handleDragOver"
|
||||
@touchend="handleDragOver"
|
||||
@touchmove="handleDragMoving"
|
||||
>
|
||||
<SliderCaptchaBar
|
||||
ref="barRef"
|
||||
:bar-style="barStyle"
|
||||
:to-left="state.toLeft"
|
||||
/>
|
||||
<SliderCaptchaContent
|
||||
ref="contentRef"
|
||||
:content-style="contentStyle"
|
||||
:is-passing="state.isPassing"
|
||||
:success-text="successText || $t('ui.captcha.sliderSuccessText')"
|
||||
:text="text || $t('ui.captcha.sliderDefaultText')"
|
||||
>
|
||||
<template v-if="$slots.text" #text>
|
||||
<slot :is-passing="state.isPassing" name="text"></slot>
|
||||
</template>
|
||||
</SliderCaptchaContent>
|
||||
|
||||
<SliderCaptchaAction
|
||||
ref="actionRef"
|
||||
:action-style="actionStyle"
|
||||
:is-passing="state.isPassing"
|
||||
:to-left="state.toLeft"
|
||||
@mousedown="handleDragStart"
|
||||
@touchstart="handleDragStart"
|
||||
>
|
||||
<template v-if="$slots.actionIcon" #icon>
|
||||
<slot :is-passing="state.isPassing" name="actionIcon"></slot>
|
||||
</template>
|
||||
</SliderCaptchaAction>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import { Check, ChevronsRight } from '@vben/icons';
|
||||
|
||||
import { Slot } from '@vben-core/shadcn-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
actionStyle: CSSProperties;
|
||||
isPassing: boolean;
|
||||
toLeft: boolean;
|
||||
}>();
|
||||
|
||||
const actionRef = useTemplateRef<HTMLDivElement>('actionRef');
|
||||
|
||||
const left = ref('0');
|
||||
|
||||
const style = computed(() => {
|
||||
const { actionStyle } = props;
|
||||
return {
|
||||
...actionStyle,
|
||||
left: left.value,
|
||||
};
|
||||
});
|
||||
|
||||
const isDragging = computed(() => {
|
||||
const currentLeft = Number.parseInt(left.value as string);
|
||||
|
||||
return currentLeft > 10 && !props.isPassing;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getEl: () => {
|
||||
return actionRef.value;
|
||||
},
|
||||
getStyle: () => {
|
||||
return actionRef?.value?.style;
|
||||
},
|
||||
setLeft: (val: string) => {
|
||||
left.value = val;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="actionRef"
|
||||
:class="{
|
||||
'transition-width !left-0 duration-300': toLeft,
|
||||
'rounded-md': isDragging,
|
||||
}"
|
||||
:style="style"
|
||||
class="bg-background dark:bg-accent absolute left-0 top-0 flex h-full cursor-move items-center justify-center px-3.5 shadow-md"
|
||||
name="captcha-action"
|
||||
>
|
||||
<Slot :is-passing="isPassing" class="text-foreground/60 size-4">
|
||||
<slot name="icon">
|
||||
<ChevronsRight v-if="!isPassing" />
|
||||
<Check v-else />
|
||||
</slot>
|
||||
</Slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
barStyle: CSSProperties;
|
||||
toLeft: boolean;
|
||||
}>();
|
||||
|
||||
const barRef = useTemplateRef<HTMLDivElement>('barRef');
|
||||
|
||||
const width = ref('0');
|
||||
|
||||
const style = computed(() => {
|
||||
const { barStyle } = props;
|
||||
return {
|
||||
...barStyle,
|
||||
width: width.value,
|
||||
};
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getEl: () => {
|
||||
return barRef.value;
|
||||
},
|
||||
setWidth: (val: string) => {
|
||||
width.value = val;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="barRef"
|
||||
:class="toLeft && 'transition-width !w-0 duration-300'"
|
||||
:style="style"
|
||||
class="bg-success absolute h-full"
|
||||
></div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
|
||||
import { VbenSpineText } from '@vben-core/shadcn-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
contentStyle: CSSProperties;
|
||||
isPassing: boolean;
|
||||
successText: string;
|
||||
text: string;
|
||||
}>();
|
||||
|
||||
const contentRef = useTemplateRef<HTMLDivElement>('contentRef');
|
||||
|
||||
const style = computed(() => {
|
||||
const { contentStyle } = props;
|
||||
|
||||
return {
|
||||
...contentStyle,
|
||||
};
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getEl: () => {
|
||||
return contentRef.value;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="contentRef"
|
||||
:class="{
|
||||
[$style.success]: isPassing,
|
||||
}"
|
||||
:style="style"
|
||||
class="absolute top-0 flex size-full select-none items-center justify-center text-xs"
|
||||
>
|
||||
<slot name="text">
|
||||
<VbenSpineText class="flex h-full items-center">
|
||||
{{ isPassing ? successText : text }}
|
||||
</VbenSpineText>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.success {
|
||||
-webkit-text-fill-color: hsl(0deg 0% 98%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
CaptchaVerifyPassingData,
|
||||
SliderCaptchaActionType,
|
||||
SliderRotateCaptchaProps,
|
||||
SliderRotateVerifyPassingData,
|
||||
} from '../types';
|
||||
|
||||
import { computed, reactive, unref, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
|
||||
import SliderCaptcha from '../slider-captcha/index.vue';
|
||||
|
||||
const props = withDefaults(defineProps<SliderRotateCaptchaProps>(), {
|
||||
defaultTip: '',
|
||||
diffDegree: 20,
|
||||
imageSize: 260,
|
||||
maxDegree: 300,
|
||||
minDegree: 120,
|
||||
src: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [CaptchaVerifyPassingData];
|
||||
}>();
|
||||
|
||||
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
|
||||
|
||||
const state = reactive({
|
||||
currentRotate: 0,
|
||||
dragging: false,
|
||||
endTime: 0,
|
||||
imgStyle: {},
|
||||
isPassing: false,
|
||||
randomRotate: 0,
|
||||
showTip: false,
|
||||
startTime: 0,
|
||||
toOrigin: false,
|
||||
});
|
||||
|
||||
const modalValue = defineModel<boolean>({ default: false });
|
||||
|
||||
watch(
|
||||
() => state.isPassing,
|
||||
(isPassing) => {
|
||||
if (isPassing) {
|
||||
const { endTime, startTime } = state;
|
||||
const time = (endTime - startTime) / 1000;
|
||||
emit('success', { isPassing, time: time.toFixed(1) });
|
||||
}
|
||||
modalValue.value = isPassing;
|
||||
},
|
||||
);
|
||||
|
||||
const getImgWrapStyleRef = computed(() => {
|
||||
const { imageSize, imageWrapperStyle } = props;
|
||||
return {
|
||||
height: `${imageSize}px`,
|
||||
width: `${imageSize}px`,
|
||||
...imageWrapperStyle,
|
||||
};
|
||||
});
|
||||
|
||||
const getFactorRef = computed(() => {
|
||||
const { maxDegree, minDegree } = props;
|
||||
if (minDegree > maxDegree) {
|
||||
console.warn('minDegree should not be greater than maxDegree');
|
||||
}
|
||||
|
||||
if (minDegree === maxDegree) {
|
||||
return Math.floor(1 + Math.random() * 1) / 10 + 1;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
function handleStart() {
|
||||
state.startTime = Date.now();
|
||||
}
|
||||
|
||||
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
|
||||
state.dragging = true;
|
||||
const { imageSize, maxDegree } = props;
|
||||
const { moveX } = data;
|
||||
const denominator = imageSize!;
|
||||
if (denominator === 0) {
|
||||
return;
|
||||
}
|
||||
const currentRotate = Math.ceil(
|
||||
(moveX / denominator) * 1.5 * maxDegree! * unref(getFactorRef),
|
||||
);
|
||||
state.currentRotate = currentRotate;
|
||||
setImgRotate(state.randomRotate - currentRotate);
|
||||
}
|
||||
|
||||
function handleImgOnLoad() {
|
||||
const { maxDegree, minDegree } = props;
|
||||
const ranRotate = Math.floor(
|
||||
minDegree! + Math.random() * (maxDegree! - minDegree!),
|
||||
); // 生成随机角度
|
||||
state.randomRotate = ranRotate;
|
||||
setImgRotate(ranRotate);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
const { currentRotate, randomRotate } = state;
|
||||
const { diffDegree } = props;
|
||||
|
||||
if (Math.abs(randomRotate - currentRotate) >= (diffDegree || 20)) {
|
||||
setImgRotate(randomRotate);
|
||||
state.toOrigin = true;
|
||||
useTimeoutFn(() => {
|
||||
state.toOrigin = false;
|
||||
state.showTip = true;
|
||||
// 时间与动画时间保持一致
|
||||
}, 300);
|
||||
} else {
|
||||
checkPass();
|
||||
}
|
||||
state.showTip = true;
|
||||
state.dragging = false;
|
||||
}
|
||||
|
||||
function setImgRotate(deg: number) {
|
||||
state.imgStyle = {
|
||||
transform: `rotateZ(${deg}deg)`,
|
||||
};
|
||||
}
|
||||
|
||||
function checkPass() {
|
||||
state.isPassing = true;
|
||||
state.endTime = Date.now();
|
||||
}
|
||||
|
||||
function resume() {
|
||||
state.showTip = false;
|
||||
const basicEl = unref(slideBarRef);
|
||||
if (!basicEl) {
|
||||
return;
|
||||
}
|
||||
state.isPassing = false;
|
||||
|
||||
basicEl.resume();
|
||||
handleImgOnLoad();
|
||||
}
|
||||
|
||||
const imgCls = computed(() => {
|
||||
return state.toOrigin ? ['transition-transform duration-300'] : [];
|
||||
});
|
||||
|
||||
const verifyTip = computed(() => {
|
||||
return state.isPassing
|
||||
? $t('ui.captcha.sliderRotateSuccessTip', [
|
||||
((state.endTime - state.startTime) / 1000).toFixed(1),
|
||||
])
|
||||
: $t('ui.captcha.sliderRotateFailTip');
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
resume,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div
|
||||
:style="getImgWrapStyleRef"
|
||||
class="border-border relative cursor-pointer overflow-hidden rounded-full border shadow-md"
|
||||
>
|
||||
<img
|
||||
:class="imgCls"
|
||||
:src="src"
|
||||
:style="state.imgStyle"
|
||||
alt="verify"
|
||||
class="w-full rounded-full"
|
||||
@click="resume"
|
||||
@load="handleImgOnLoad"
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-3 left-0 z-10 block h-7 w-full text-center text-xs leading-[30px] text-white"
|
||||
>
|
||||
<div
|
||||
v-if="state.showTip"
|
||||
:class="{
|
||||
'bg-success/80': state.isPassing,
|
||||
'bg-destructive/80': !state.isPassing,
|
||||
}"
|
||||
>
|
||||
{{ verifyTip }}
|
||||
</div>
|
||||
<div v-if="!state.dragging" class="bg-black/30">
|
||||
{{ defaultTip || $t('ui.captcha.sliderRotateDefaultTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SliderCaptcha
|
||||
ref="slideBarRef"
|
||||
v-model="modalValue"
|
||||
class="mt-5"
|
||||
is-slot
|
||||
@end="handleDragEnd"
|
||||
@move="handleDragBarMove"
|
||||
@start="handleStart"
|
||||
>
|
||||
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
|
||||
<slot :name="key" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</SliderCaptcha>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
CaptchaVerifyPassingData,
|
||||
SliderCaptchaActionType,
|
||||
SliderRotateVerifyPassingData,
|
||||
SliderTranslateCaptchaProps,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
unref,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SliderCaptcha from '../slider-captcha/index.vue';
|
||||
|
||||
const props = withDefaults(defineProps<SliderTranslateCaptchaProps>(), {
|
||||
defaultTip: '',
|
||||
canvasWidth: 420,
|
||||
canvasHeight: 280,
|
||||
squareLength: 42,
|
||||
circleRadius: 10,
|
||||
src: '',
|
||||
diffDistance: 3,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [CaptchaVerifyPassingData];
|
||||
}>();
|
||||
|
||||
const PI: number = Math.PI;
|
||||
enum CanvasOpr {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Clip = 'clip',
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
Fill = 'fill',
|
||||
}
|
||||
|
||||
const modalValue = defineModel<boolean>({ default: false });
|
||||
|
||||
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
|
||||
const puzzleCanvasRef = useTemplateRef<HTMLCanvasElement>('puzzleCanvasRef');
|
||||
const pieceCanvasRef = useTemplateRef<HTMLCanvasElement>('pieceCanvasRef');
|
||||
|
||||
const state = reactive({
|
||||
dragging: false,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
pieceX: 0,
|
||||
pieceY: 0,
|
||||
moveDistance: 0,
|
||||
isPassing: false,
|
||||
showTip: false,
|
||||
});
|
||||
|
||||
const left = ref('0');
|
||||
|
||||
const pieceStyle = computed(() => {
|
||||
return {
|
||||
left: left.value,
|
||||
};
|
||||
});
|
||||
|
||||
function setLeft(val: string) {
|
||||
left.value = val;
|
||||
}
|
||||
|
||||
const verifyTip = computed(() => {
|
||||
return state.isPassing
|
||||
? $t('ui.captcha.sliderTranslateSuccessTip', [
|
||||
((state.endTime - state.startTime) / 1000).toFixed(1),
|
||||
])
|
||||
: $t('ui.captcha.sliderTranslateFailTip');
|
||||
});
|
||||
function handleStart() {
|
||||
state.startTime = Date.now();
|
||||
}
|
||||
|
||||
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
|
||||
state.dragging = true;
|
||||
const { moveX } = data;
|
||||
state.moveDistance = moveX;
|
||||
setLeft(`${moveX}px`);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
const { pieceX } = state;
|
||||
const { diffDistance } = props;
|
||||
|
||||
if (Math.abs(pieceX - state.moveDistance) >= (diffDistance || 3)) {
|
||||
setLeft('0');
|
||||
state.moveDistance = 0;
|
||||
} else {
|
||||
checkPass();
|
||||
}
|
||||
state.showTip = true;
|
||||
state.dragging = false;
|
||||
}
|
||||
|
||||
function checkPass() {
|
||||
state.isPassing = true;
|
||||
state.endTime = Date.now();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => state.isPassing,
|
||||
(isPassing) => {
|
||||
if (isPassing) {
|
||||
const { endTime, startTime } = state;
|
||||
const time = (endTime - startTime) / 1000;
|
||||
emit('success', { isPassing, time: time.toFixed(1) });
|
||||
}
|
||||
modalValue.value = isPassing;
|
||||
},
|
||||
);
|
||||
|
||||
function resetCanvas() {
|
||||
const { canvasWidth, canvasHeight } = props;
|
||||
const puzzleCanvas = unref(puzzleCanvasRef);
|
||||
const pieceCanvas = unref(pieceCanvasRef);
|
||||
if (!puzzleCanvas || !pieceCanvas) return;
|
||||
pieceCanvas.width = canvasWidth;
|
||||
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
|
||||
// Canvas2D: Multiple readback operations using getImageData
|
||||
// are faster with the willReadFrequently attribute set to true.
|
||||
// See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
|
||||
const pieceCanvasCtx = pieceCanvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
||||
puzzleCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
pieceCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
const { canvasWidth, canvasHeight, squareLength, circleRadius, src } = props;
|
||||
const puzzleCanvas = unref(puzzleCanvasRef);
|
||||
const pieceCanvas = unref(pieceCanvasRef);
|
||||
if (!puzzleCanvas || !pieceCanvas) return;
|
||||
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
|
||||
// Canvas2D: Multiple readback operations using getImageData
|
||||
// are faster with the willReadFrequently attribute set to true.
|
||||
// See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
|
||||
const pieceCanvasCtx = pieceCanvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
||||
const img = new Image();
|
||||
// 解决跨域
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.src = src;
|
||||
img.addEventListener('load', () => {
|
||||
draw(puzzleCanvasCtx, pieceCanvasCtx);
|
||||
puzzleCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
|
||||
pieceCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
|
||||
const pieceLength = squareLength + 2 * circleRadius + 3;
|
||||
const sx = state.pieceX;
|
||||
const sy = state.pieceY - 2 * circleRadius - 1;
|
||||
const imageData = pieceCanvasCtx.getImageData(
|
||||
sx,
|
||||
sy,
|
||||
pieceLength,
|
||||
pieceLength,
|
||||
);
|
||||
pieceCanvas.width = pieceLength;
|
||||
pieceCanvasCtx.putImageData(imageData, 0, sy);
|
||||
setLeft('0');
|
||||
});
|
||||
}
|
||||
|
||||
function getRandomNumberByRange(start: number, end: number) {
|
||||
return Math.round(Math.random() * (end - start) + start);
|
||||
}
|
||||
|
||||
// 绘制拼图
|
||||
function draw(ctx1: CanvasRenderingContext2D, ctx2: CanvasRenderingContext2D) {
|
||||
const { canvasWidth, canvasHeight, squareLength, circleRadius } = props;
|
||||
state.pieceX = getRandomNumberByRange(
|
||||
squareLength + 2 * circleRadius,
|
||||
canvasWidth - (squareLength + 2 * circleRadius),
|
||||
);
|
||||
state.pieceY = getRandomNumberByRange(
|
||||
3 * circleRadius,
|
||||
canvasHeight - (squareLength + 2 * circleRadius),
|
||||
);
|
||||
drawPiece(ctx1, state.pieceX, state.pieceY, CanvasOpr.Fill);
|
||||
drawPiece(ctx2, state.pieceX, state.pieceY, CanvasOpr.Clip);
|
||||
}
|
||||
|
||||
// 绘制拼图切块
|
||||
function drawPiece(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
opr: CanvasOpr,
|
||||
) {
|
||||
const { squareLength, circleRadius } = props;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.arc(
|
||||
x + squareLength / 2,
|
||||
y - circleRadius + 2,
|
||||
circleRadius,
|
||||
0.72 * PI,
|
||||
2.26 * PI,
|
||||
);
|
||||
ctx.lineTo(x + squareLength, y);
|
||||
ctx.arc(
|
||||
x + squareLength + circleRadius - 2,
|
||||
y + squareLength / 2,
|
||||
circleRadius,
|
||||
1.21 * PI,
|
||||
2.78 * PI,
|
||||
);
|
||||
ctx.lineTo(x + squareLength, y + squareLength);
|
||||
ctx.lineTo(x, y + squareLength);
|
||||
ctx.arc(
|
||||
x + circleRadius - 2,
|
||||
y + squareLength / 2,
|
||||
circleRadius + 0.4,
|
||||
2.76 * PI,
|
||||
1.24 * PI,
|
||||
true,
|
||||
);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.stroke();
|
||||
opr === CanvasOpr.Clip ? ctx.clip() : ctx.fill();
|
||||
ctx.globalCompositeOperation = 'destination-over';
|
||||
}
|
||||
|
||||
function resume() {
|
||||
state.showTip = false;
|
||||
const basicEl = unref(slideBarRef);
|
||||
if (!basicEl) {
|
||||
return;
|
||||
}
|
||||
state.dragging = false;
|
||||
state.isPassing = false;
|
||||
state.pieceX = 0;
|
||||
state.pieceY = 0;
|
||||
|
||||
basicEl.resume();
|
||||
resetCanvas();
|
||||
initCanvas();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div
|
||||
class="border-border relative flex cursor-pointer overflow-hidden border shadow-md"
|
||||
>
|
||||
<canvas
|
||||
ref="puzzleCanvasRef"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
@click="resume"
|
||||
></canvas>
|
||||
<canvas
|
||||
ref="pieceCanvasRef"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
:style="pieceStyle"
|
||||
class="absolute"
|
||||
@click="resume"
|
||||
></canvas>
|
||||
<div
|
||||
class="h-15 absolute bottom-3 left-0 z-10 block w-full text-center text-xs leading-[30px] text-white"
|
||||
>
|
||||
<div
|
||||
v-if="state.showTip"
|
||||
:class="{
|
||||
'bg-success/80': state.isPassing,
|
||||
'bg-destructive/80': !state.isPassing,
|
||||
}"
|
||||
>
|
||||
{{ verifyTip }}
|
||||
</div>
|
||||
<div v-if="!state.dragging" class="bg-black/30">
|
||||
{{ defaultTip || $t('ui.captcha.sliderTranslateDefaultTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SliderCaptcha
|
||||
ref="slideBarRef"
|
||||
v-model="modalValue"
|
||||
class="mt-5"
|
||||
is-slot
|
||||
@end="handleDragEnd"
|
||||
@move="handleDragBarMove"
|
||||
@start="handleStart"
|
||||
>
|
||||
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
|
||||
<slot :name="key" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</SliderCaptcha>
|
||||
</div>
|
||||
</template>
|
||||
211
packages/effects/common-ui/src/components/captcha/types.ts
Normal file
211
packages/effects/common-ui/src/components/captcha/types.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { ClassType } from '@vben/types';
|
||||
|
||||
export interface CaptchaData {
|
||||
/**
|
||||
* x
|
||||
*/
|
||||
x: number;
|
||||
/**
|
||||
* y
|
||||
*/
|
||||
y: number;
|
||||
/**
|
||||
* 时间戳
|
||||
*/
|
||||
t: number;
|
||||
}
|
||||
export interface CaptchaPoint extends CaptchaData {
|
||||
/**
|
||||
* 数据索引
|
||||
*/
|
||||
i: number;
|
||||
}
|
||||
export interface PointSelectionCaptchaCardProps {
|
||||
/**
|
||||
* 验证码图片
|
||||
*/
|
||||
captchaImage: string;
|
||||
/**
|
||||
* 验证码图片高度
|
||||
* @default '220px'
|
||||
*/
|
||||
height?: number | string;
|
||||
/**
|
||||
* 水平内边距
|
||||
* @default '12px'
|
||||
*/
|
||||
paddingX?: number | string;
|
||||
/**
|
||||
* 垂直内边距
|
||||
* @default '16px'
|
||||
*/
|
||||
paddingY?: number | string;
|
||||
/**
|
||||
* 标题
|
||||
* @default '请按图依次点击'
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 验证码图片宽度
|
||||
* @default '300px'
|
||||
*/
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export interface PointSelectionCaptchaProps
|
||||
extends PointSelectionCaptchaCardProps {
|
||||
/**
|
||||
* 是否展示确定按钮
|
||||
* @default false
|
||||
*/
|
||||
showConfirm?: boolean;
|
||||
/**
|
||||
* 提示图片
|
||||
* @default ''
|
||||
*/
|
||||
hintImage?: string;
|
||||
/**
|
||||
* 提示文本
|
||||
* @default ''
|
||||
*/
|
||||
hintText?: string;
|
||||
}
|
||||
|
||||
export interface SliderCaptchaProps {
|
||||
class?: ClassType;
|
||||
/**
|
||||
* @description 滑块的样式
|
||||
* @default {}
|
||||
*/
|
||||
actionStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 滑块条的样式
|
||||
* @default {}
|
||||
*/
|
||||
barStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 内容的样式
|
||||
* @default {}
|
||||
*/
|
||||
contentStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 组件的样式
|
||||
* @default {}
|
||||
*/
|
||||
wrapperStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 是否作为插槽使用,用于联动组件,可参考旋转校验组件
|
||||
* @default false
|
||||
*/
|
||||
isSlot?: boolean;
|
||||
|
||||
/**
|
||||
* @description 验证成功的提示
|
||||
* @default '验证通过'
|
||||
*/
|
||||
successText?: string;
|
||||
|
||||
/**
|
||||
* @description 提示文字
|
||||
* @default '请按住滑块拖动'
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface SliderRotateCaptchaProps {
|
||||
/**
|
||||
* @description 旋转的角度
|
||||
* @default 20
|
||||
*/
|
||||
diffDegree?: number;
|
||||
|
||||
/**
|
||||
* @description 图片的宽度
|
||||
* @default 260
|
||||
*/
|
||||
imageSize?: number;
|
||||
|
||||
/**
|
||||
* @description 图片的样式
|
||||
* @default {}
|
||||
*/
|
||||
imageWrapperStyle?: CSSProperties;
|
||||
|
||||
/**
|
||||
* @description 最大旋转角度
|
||||
* @default 270
|
||||
*/
|
||||
maxDegree?: number;
|
||||
|
||||
/**
|
||||
* @description 最小旋转角度
|
||||
* @default 90
|
||||
*/
|
||||
minDegree?: number;
|
||||
|
||||
/**
|
||||
* @description 图片的地址
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* @description 默认提示文本
|
||||
*/
|
||||
defaultTip?: string;
|
||||
}
|
||||
|
||||
export interface SliderTranslateCaptchaProps {
|
||||
/**
|
||||
* @description 拼图的宽度
|
||||
* @default 420
|
||||
*/
|
||||
canvasWidth?: number;
|
||||
/**
|
||||
* @description 拼图的高度
|
||||
* @default 280
|
||||
*/
|
||||
canvasHeight?: number;
|
||||
/**
|
||||
* @description 切块上正方形的长度
|
||||
* @default 42
|
||||
*/
|
||||
squareLength?: number;
|
||||
/**
|
||||
* @description 切块上圆形的半径
|
||||
* @default 10
|
||||
*/
|
||||
circleRadius?: number;
|
||||
/**
|
||||
* @description 图片的地址
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* @description 允许的最大差距
|
||||
* @default 3
|
||||
*/
|
||||
diffDistance?: number;
|
||||
/**
|
||||
* @description 默认提示文本
|
||||
*/
|
||||
defaultTip?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaVerifyPassingData {
|
||||
isPassing: boolean;
|
||||
time: number | string;
|
||||
}
|
||||
|
||||
export interface SliderCaptchaActionType {
|
||||
resume: () => void;
|
||||
}
|
||||
|
||||
export interface SliderRotateVerifyPassingData {
|
||||
event: MouseEvent | TouchEvent;
|
||||
moveDistance: number;
|
||||
moveX: number;
|
||||
}
|
||||
107
packages/effects/common-ui/src/components/col-page/col-page.vue
Normal file
107
packages/effects/common-ui/src/components/col-page/col-page.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ColPageProps } from './types';
|
||||
|
||||
import { computed, ref, useSlots } from 'vue';
|
||||
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
import Page from '../page/page.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ColPage',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<ColPageProps>(), {
|
||||
leftWidth: 30,
|
||||
rightWidth: 70,
|
||||
resizable: true,
|
||||
});
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { leftWidth: _, ...delegated } = props;
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const delegatedSlots = computed(() => {
|
||||
const resultSlots: string[] = [];
|
||||
|
||||
for (const key of Object.keys(slots)) {
|
||||
if (!['default', 'left'].includes(key)) {
|
||||
resultSlots.push(key);
|
||||
}
|
||||
}
|
||||
return resultSlots;
|
||||
});
|
||||
|
||||
const leftPanelRef = ref<InstanceType<typeof ResizablePanel>>();
|
||||
|
||||
function expandLeft() {
|
||||
leftPanelRef.value?.expand();
|
||||
}
|
||||
|
||||
function collapseLeft() {
|
||||
leftPanelRef.value?.collapse();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
expandLeft,
|
||||
collapseLeft,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Page v-bind="delegatedProps">
|
||||
<!-- 继承默认的slot -->
|
||||
<template
|
||||
v-for="slotName in delegatedSlots"
|
||||
:key="slotName"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot :name="slotName" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
|
||||
<ResizablePanelGroup class="w-full" direction="horizontal">
|
||||
<ResizablePanel
|
||||
ref="leftPanelRef"
|
||||
:collapsed-size="leftCollapsedWidth"
|
||||
:collapsible="leftCollapsible"
|
||||
:default-size="leftWidth"
|
||||
:max-size="leftMaxWidth"
|
||||
:min-size="leftMinWidth"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<slot
|
||||
name="left"
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
expand: expandLeft,
|
||||
collapse: collapseLeft,
|
||||
}"
|
||||
></slot>
|
||||
</template>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
v-if="resizable"
|
||||
:style="{ backgroundColor: splitLine ? undefined : 'transparent' }"
|
||||
:with-handle="splitHandle"
|
||||
/>
|
||||
<ResizablePanel
|
||||
:collapsed-size="rightCollapsedWidth"
|
||||
:collapsible="rightCollapsible"
|
||||
:default-size="rightWidth"
|
||||
:max-size="rightMaxWidth"
|
||||
:min-size="rightMinWidth"
|
||||
>
|
||||
<template #default>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ColPage } from './col-page.vue';
|
||||
export * from './types';
|
||||
26
packages/effects/common-ui/src/components/col-page/types.ts
Normal file
26
packages/effects/common-ui/src/components/col-page/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { PageProps } from '../page/types';
|
||||
|
||||
export interface ColPageProps extends PageProps {
|
||||
/**
|
||||
* 左侧宽度
|
||||
* @default 30
|
||||
*/
|
||||
leftWidth?: number;
|
||||
leftMinWidth?: number;
|
||||
leftMaxWidth?: number;
|
||||
leftCollapsedWidth?: number;
|
||||
leftCollapsible?: boolean;
|
||||
/**
|
||||
* 右侧宽度
|
||||
* @default 70
|
||||
*/
|
||||
rightWidth?: number;
|
||||
rightMinWidth?: number;
|
||||
rightCollapsedWidth?: number;
|
||||
rightMaxWidth?: number;
|
||||
rightCollapsible?: boolean;
|
||||
|
||||
resizable?: boolean;
|
||||
splitLine?: boolean;
|
||||
splitHandle?: boolean;
|
||||
}
|
||||
123
packages/effects/common-ui/src/components/count-to/count-to.vue
Normal file
123
packages/effects/common-ui/src/components/count-to/count-to.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CountToProps } from './types';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { isString } from '@vben-core/shared/utils';
|
||||
|
||||
import { TransitionPresets, useTransition } from '@vueuse/core';
|
||||
|
||||
const props = withDefaults(defineProps<CountToProps>(), {
|
||||
startVal: 0,
|
||||
duration: 2000,
|
||||
separator: ',',
|
||||
decimal: '.',
|
||||
decimals: 0,
|
||||
delay: 0,
|
||||
transition: () => TransitionPresets.easeOutExpo,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['started', 'finished']);
|
||||
|
||||
const lastValue = ref(props.startVal);
|
||||
|
||||
onMounted(() => {
|
||||
lastValue.value = props.endVal;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.endVal,
|
||||
(val) => {
|
||||
lastValue.value = val;
|
||||
},
|
||||
);
|
||||
|
||||
const currentValue = useTransition(lastValue, {
|
||||
delay: computed(() => props.delay),
|
||||
duration: computed(() => props.duration),
|
||||
disabled: computed(() => props.disabled),
|
||||
transition: computed(() => {
|
||||
return isString(props.transition)
|
||||
? TransitionPresets[props.transition]
|
||||
: props.transition;
|
||||
}),
|
||||
onStarted() {
|
||||
emit('started');
|
||||
},
|
||||
onFinished() {
|
||||
emit('finished');
|
||||
},
|
||||
});
|
||||
|
||||
const numMain = computed(() => {
|
||||
const result = currentValue.value
|
||||
.toFixed(props.decimals)
|
||||
.split('.')[0]
|
||||
?.replaceAll(/\B(?=(\d{3})+(?!\d))/g, props.separator);
|
||||
return result;
|
||||
});
|
||||
|
||||
const numDec = computed(() => {
|
||||
return (
|
||||
props.decimal + currentValue.value.toFixed(props.decimals).split('.')[1]
|
||||
);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="count-to" v-bind="$attrs">
|
||||
<slot name="prefix">
|
||||
<div
|
||||
class="count-to-prefix"
|
||||
:style="prefixStyle"
|
||||
:class="prefixClass"
|
||||
v-if="prefix"
|
||||
>
|
||||
{{ prefix }}
|
||||
</div>
|
||||
</slot>
|
||||
<div class="count-to-main" :class="mainClass" :style="mainStyle">
|
||||
<span>{{ numMain }}</span>
|
||||
<span
|
||||
class="count-to-main-decimal"
|
||||
v-if="decimals > 0"
|
||||
:class="decimalClass"
|
||||
:style="decimalStyle"
|
||||
>
|
||||
{{ numDec }}
|
||||
</span>
|
||||
</div>
|
||||
<slot name="suffix">
|
||||
<div
|
||||
class="count-to-suffix"
|
||||
:style="suffixStyle"
|
||||
:class="suffixClass"
|
||||
v-if="suffix"
|
||||
>
|
||||
{{ suffix }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.count-to {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
&-prefix {
|
||||
// font-size: 1rem;
|
||||
}
|
||||
|
||||
&-suffix {
|
||||
// font-size: 1rem;
|
||||
}
|
||||
|
||||
&-main {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
// font-size: 1.5rem;
|
||||
|
||||
&-decimal {
|
||||
// font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CountTo } from './count-to.vue';
|
||||
export * from './types';
|
||||
53
packages/effects/common-ui/src/components/count-to/types.ts
Normal file
53
packages/effects/common-ui/src/components/count-to/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { CubicBezierPoints, EasingFunction } from '@vueuse/core';
|
||||
|
||||
import type { StyleValue } from 'vue';
|
||||
|
||||
import { TransitionPresets as TransitionPresetsData } from '@vueuse/core';
|
||||
|
||||
export type TransitionPresets = keyof typeof TransitionPresetsData;
|
||||
|
||||
export const TransitionPresetsKeys = Object.keys(
|
||||
TransitionPresetsData,
|
||||
) as TransitionPresets[];
|
||||
|
||||
export interface CountToProps {
|
||||
/** 初始值 */
|
||||
startVal?: number;
|
||||
/** 当前值 */
|
||||
endVal: number;
|
||||
/** 是否禁用动画 */
|
||||
disabled?: boolean;
|
||||
/** 延迟动画开始的时间 */
|
||||
delay?: number;
|
||||
/** 持续时间 */
|
||||
duration?: number;
|
||||
/** 小数位数 */
|
||||
decimals?: number;
|
||||
/** 小数点 */
|
||||
decimal?: string;
|
||||
/** 分隔符 */
|
||||
separator?: string;
|
||||
/** 前缀 */
|
||||
prefix?: string;
|
||||
/** 后缀 */
|
||||
suffix?: string;
|
||||
/** 过渡效果 */
|
||||
transition?: CubicBezierPoints | EasingFunction | TransitionPresets;
|
||||
/** 整数部分的类名 */
|
||||
mainClass?: string;
|
||||
/** 小数部分的类名 */
|
||||
decimalClass?: string;
|
||||
/** 前缀部分的类名 */
|
||||
prefixClass?: string;
|
||||
/** 后缀部分的类名 */
|
||||
suffixClass?: string;
|
||||
|
||||
/** 整数部分的样式 */
|
||||
mainStyle?: StyleValue;
|
||||
/** 小数部分的样式 */
|
||||
decimalStyle?: StyleValue;
|
||||
/** 前缀部分的样式 */
|
||||
prefixStyle?: StyleValue;
|
||||
/** 后缀部分的样式 */
|
||||
suffixStyle?: StyleValue;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
|
||||
import { VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 是否启用点击文本展开全部
|
||||
* @default false
|
||||
*/
|
||||
expand?: boolean;
|
||||
/**
|
||||
* 文本最大行数
|
||||
* @default 1
|
||||
*/
|
||||
line?: number;
|
||||
/**
|
||||
* 文本最大宽度
|
||||
* @default '100%'
|
||||
*/
|
||||
maxWidth?: number | string;
|
||||
/**
|
||||
* 提示框位置
|
||||
* @default 'top'
|
||||
*/
|
||||
placement?: 'bottom' | 'left' | 'right' | 'top';
|
||||
/**
|
||||
* 是否启用文本提示框
|
||||
* @default true
|
||||
*/
|
||||
tooltip?: boolean;
|
||||
/**
|
||||
* 是否只在文本被截断时显示提示框
|
||||
* @default false
|
||||
*/
|
||||
tooltipWhenEllipsis?: boolean;
|
||||
/**
|
||||
* 文本截断检测的像素差异阈值,越大则判断越严格
|
||||
* @default 3
|
||||
*/
|
||||
ellipsisThreshold?: number;
|
||||
/**
|
||||
* 提示框背景颜色,优先级高于 overlayStyle
|
||||
*/
|
||||
tooltipBackgroundColor?: string;
|
||||
/**
|
||||
* 提示文本字体颜色,优先级高于 overlayStyle
|
||||
*/
|
||||
tooltipColor?: string;
|
||||
/**
|
||||
* 提示文本字体大小,单位px,优先级高于 overlayStyle
|
||||
*/
|
||||
tooltipFontSize?: number;
|
||||
/**
|
||||
* 提示框内容最大宽度,单位px,默认不设置时,提示文本内容自动与展示文本宽度保持一致
|
||||
*/
|
||||
tooltipMaxWidth?: number;
|
||||
/**
|
||||
* 提示框内容区域样式
|
||||
* @default { textAlign: 'justify' }
|
||||
*/
|
||||
tooltipOverlayStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
expand: false,
|
||||
line: 1,
|
||||
maxWidth: '100%',
|
||||
placement: 'top',
|
||||
tooltip: true,
|
||||
tooltipWhenEllipsis: false,
|
||||
ellipsisThreshold: 3,
|
||||
tooltipBackgroundColor: '',
|
||||
tooltipColor: '',
|
||||
tooltipFontSize: 14,
|
||||
tooltipMaxWidth: undefined,
|
||||
tooltipOverlayStyle: () => ({ textAlign: 'justify' }),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ expandChange: [boolean] }>();
|
||||
|
||||
const textMaxWidth = computed(() => {
|
||||
if (typeof props.maxWidth === 'number') {
|
||||
return `${props.maxWidth}px`;
|
||||
}
|
||||
return props.maxWidth;
|
||||
});
|
||||
const ellipsis = ref();
|
||||
const isExpand = ref(false);
|
||||
const defaultTooltipMaxWidth = ref();
|
||||
const isEllipsis = ref(false);
|
||||
|
||||
const { width: eleWidth } = useElementSize(ellipsis);
|
||||
|
||||
// 检测文本是否被截断
|
||||
const checkEllipsis = () => {
|
||||
if (!ellipsis.value || !props.tooltipWhenEllipsis) return;
|
||||
|
||||
const element = ellipsis.value;
|
||||
|
||||
const originalText = element.textContent || '';
|
||||
const originalTrimmed = originalText.trim();
|
||||
|
||||
// 对于空文本直接返回 false
|
||||
if (!originalTrimmed) {
|
||||
isEllipsis.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const widthDiff = element.scrollWidth - element.clientWidth;
|
||||
const heightDiff = element.scrollHeight - element.clientHeight;
|
||||
|
||||
// 使用足够大的差异阈值确保只有真正被截断的文本才会显示 tooltip
|
||||
isEllipsis.value =
|
||||
props.line === 1
|
||||
? widthDiff > props.ellipsisThreshold
|
||||
: heightDiff > props.ellipsisThreshold;
|
||||
};
|
||||
|
||||
// 使用 ResizeObserver 监听尺寸变化
|
||||
let resizeObserver: null | ResizeObserver = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver !== 'undefined' && props.tooltipWhenEllipsis) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
checkEllipsis();
|
||||
});
|
||||
|
||||
if (ellipsis.value) {
|
||||
resizeObserver.observe(ellipsis.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始检测
|
||||
checkEllipsis();
|
||||
});
|
||||
|
||||
// 使用onUpdated钩子检测内容变化
|
||||
onUpdated(() => {
|
||||
if (props.tooltipWhenEllipsis) {
|
||||
checkEllipsis();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (props.tooltip && eleWidth.value) {
|
||||
defaultTooltipMaxWidth.value =
|
||||
props.tooltipMaxWidth ?? eleWidth.value + 24;
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
function onExpand() {
|
||||
isExpand.value = !isExpand.value;
|
||||
emit('expandChange', isExpand.value);
|
||||
if (props.tooltipWhenEllipsis) {
|
||||
checkEllipsis();
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpand() {
|
||||
props.expand && onExpand();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VbenTooltip
|
||||
:content-style="{
|
||||
...tooltipOverlayStyle,
|
||||
maxWidth: `${defaultTooltipMaxWidth}px`,
|
||||
fontSize: `${tooltipFontSize}px`,
|
||||
color: tooltipColor,
|
||||
backgroundColor: tooltipBackgroundColor,
|
||||
}"
|
||||
:disabled="
|
||||
!props.tooltip || isExpand || (props.tooltipWhenEllipsis && !isEllipsis)
|
||||
"
|
||||
:side="placement"
|
||||
>
|
||||
<slot name="tooltip">
|
||||
<slot></slot>
|
||||
</slot>
|
||||
|
||||
<template #trigger>
|
||||
<div
|
||||
ref="ellipsis"
|
||||
:class="{
|
||||
'!cursor-pointer': expand,
|
||||
['block truncate']: line === 1,
|
||||
[$style.ellipsisMultiLine]: line > 1,
|
||||
}"
|
||||
:style="{
|
||||
'-webkit-line-clamp': isExpand ? '' : line,
|
||||
'max-width': textMaxWidth,
|
||||
}"
|
||||
class="cursor-text overflow-hidden"
|
||||
@click="handleExpand"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
</VbenTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.ellipsisMultiLine {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as EllipsisText } from './ellipsis-text.vue';
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
/**
|
||||
* 一个缓存对象,在不刷新页面时,无需重复请求远程接口
|
||||
*/
|
||||
export const ICONS_MAP: Recordable<string[]> = {};
|
||||
|
||||
interface IconifyResponse {
|
||||
prefix: string;
|
||||
total: number;
|
||||
title: string;
|
||||
uncategorized?: string[];
|
||||
categories?: Recordable<string[]>;
|
||||
aliases?: Recordable<string>;
|
||||
}
|
||||
|
||||
const PENDING_REQUESTS: Recordable<Promise<string[]>> = {};
|
||||
|
||||
/**
|
||||
* 通过Iconify接口获取图标集数据。
|
||||
* 同一时间多个图标选择器同时请求同一个图标集时,实际上只会发起一次请求(所有请求共享同一份结果)。
|
||||
* 请求结果会被缓存,刷新页面前同一个图标集不会再次请求
|
||||
* @param prefix 图标集名称
|
||||
* @returns 图标集中包含的所有图标名称
|
||||
*/
|
||||
export async function fetchIconsData(prefix: string): Promise<string[]> {
|
||||
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
|
||||
return ICONS_MAP[prefix];
|
||||
}
|
||||
if (Reflect.has(PENDING_REQUESTS, prefix) && PENDING_REQUESTS[prefix]) {
|
||||
return PENDING_REQUESTS[prefix];
|
||||
}
|
||||
PENDING_REQUESTS[prefix] = (async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000 * 10);
|
||||
const response: IconifyResponse = await fetch(
|
||||
`https://api.iconify.design/collection?prefix=${prefix}`,
|
||||
{ signal: controller.signal },
|
||||
).then((res) => res.json());
|
||||
clearTimeout(timeoutId);
|
||||
const list = response.uncategorized || [];
|
||||
if (response.categories) {
|
||||
for (const category in response.categories) {
|
||||
list.push(...(response.categories[category] || []));
|
||||
}
|
||||
}
|
||||
ICONS_MAP[prefix] = list.map((v) => `${prefix}:${v}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch icons for prefix ${prefix}:`, error);
|
||||
return [] as string[];
|
||||
}
|
||||
return ICONS_MAP[prefix];
|
||||
})();
|
||||
return PENDING_REQUESTS[prefix];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as IconPicker } from './icon-picker.vue';
|
||||
34
packages/effects/common-ui/src/components/index.ts
Normal file
34
packages/effects/common-ui/src/components/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export * from './api-component';
|
||||
export * from './captcha';
|
||||
export * from './col-page';
|
||||
export * from './count-to';
|
||||
export * from './ellipsis-text';
|
||||
export * from './icon-picker';
|
||||
export * from './json-viewer';
|
||||
export * from './loading';
|
||||
export * from './page';
|
||||
export * from './resize';
|
||||
export * from './tippy';
|
||||
export * from './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';
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as JsonViewer } from './index.vue';
|
||||
|
||||
export * from './types';
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface JsonViewerProps {
|
||||
/** 要展示的结构数据 */
|
||||
value: any;
|
||||
/** 展开深度 */
|
||||
expandDepth?: number;
|
||||
/** 是否可复制 */
|
||||
copyable?: boolean;
|
||||
/** 是否排序 */
|
||||
sort?: boolean;
|
||||
/** 显示边框 */
|
||||
boxed?: boolean;
|
||||
/** 主题 */
|
||||
theme?: string;
|
||||
/** 是否展开 */
|
||||
expanded?: boolean;
|
||||
/** 时间格式化函数 */
|
||||
timeformat?: (time: Date | number | string) => string;
|
||||
/** 预览模式 */
|
||||
previewMode?: boolean;
|
||||
/** 显示数组索引 */
|
||||
showArrayIndex?: boolean;
|
||||
/** 显示双引号 */
|
||||
showDoubleQuotes?: boolean;
|
||||
}
|
||||
|
||||
export interface JsonViewerAction {
|
||||
action: string;
|
||||
text: string;
|
||||
trigger: HTMLElement;
|
||||
}
|
||||
|
||||
export interface JsonViewerValue {
|
||||
value: any;
|
||||
path: string;
|
||||
depth: number;
|
||||
el: HTMLElement;
|
||||
}
|
||||
|
||||
export interface JsonViewerToggle {
|
||||
/** 鼠标事件 */
|
||||
event: MouseEvent;
|
||||
/** 当前展开状态 */
|
||||
open: boolean;
|
||||
}
|
||||
132
packages/effects/common-ui/src/components/loading/directive.ts
Normal file
132
packages/effects/common-ui/src/components/loading/directive.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { App, Directive, DirectiveBinding } from 'vue';
|
||||
|
||||
import { h, render } from 'vue';
|
||||
|
||||
import { VbenLoading, VbenSpinner } from '@vben-core/shadcn-ui';
|
||||
import { isString } from '@vben-core/shared/utils';
|
||||
|
||||
const LOADING_INSTANCE_KEY = Symbol('loading');
|
||||
const SPINNER_INSTANCE_KEY = Symbol('spinner');
|
||||
|
||||
const CLASS_NAME_RELATIVE = 'spinner-parent--relative';
|
||||
|
||||
const loadingDirective: Directive = {
|
||||
mounted(el, binding) {
|
||||
const instance = h(VbenLoading, getOptions(binding));
|
||||
render(instance, el);
|
||||
|
||||
el.classList.add(CLASS_NAME_RELATIVE);
|
||||
el[LOADING_INSTANCE_KEY] = instance;
|
||||
},
|
||||
unmounted(el) {
|
||||
const instance = el[LOADING_INSTANCE_KEY];
|
||||
el.classList.remove(CLASS_NAME_RELATIVE);
|
||||
render(null, el);
|
||||
instance.el.remove();
|
||||
|
||||
el[LOADING_INSTANCE_KEY] = null;
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const instance = el[LOADING_INSTANCE_KEY];
|
||||
const options = getOptions(binding);
|
||||
if (options && instance?.component) {
|
||||
try {
|
||||
Object.keys(options).forEach((key) => {
|
||||
instance.component.props[key] = options[key];
|
||||
});
|
||||
instance.component.update();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to update loading component in directive:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function getOptions(binding: DirectiveBinding) {
|
||||
if (binding.value === undefined) {
|
||||
return { spinning: true };
|
||||
} else if (typeof binding.value === 'boolean') {
|
||||
return { spinning: binding.value };
|
||||
} else {
|
||||
return { ...binding.value };
|
||||
}
|
||||
}
|
||||
|
||||
const spinningDirective: Directive = {
|
||||
mounted(el, binding) {
|
||||
const instance = h(VbenSpinner, getOptions(binding));
|
||||
render(instance, el);
|
||||
|
||||
el.classList.add(CLASS_NAME_RELATIVE);
|
||||
el[SPINNER_INSTANCE_KEY] = instance;
|
||||
},
|
||||
unmounted(el) {
|
||||
const instance = el[SPINNER_INSTANCE_KEY];
|
||||
el.classList.remove(CLASS_NAME_RELATIVE);
|
||||
render(null, el);
|
||||
instance.el.remove();
|
||||
|
||||
el[SPINNER_INSTANCE_KEY] = null;
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const instance = el[SPINNER_INSTANCE_KEY];
|
||||
const options = getOptions(binding);
|
||||
if (options && instance?.component) {
|
||||
try {
|
||||
Object.keys(options).forEach((key) => {
|
||||
instance.component.props[key] = options[key];
|
||||
});
|
||||
instance.component.update();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to update spinner component in directive:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
type loadingDirectiveParams = {
|
||||
/** 是否注册loading指令。如果提供一个string,则将指令注册为指定的名称 */
|
||||
loading?: boolean | string;
|
||||
/** 是否注册spinning指令。如果提供一个string,则将指令注册为指定的名称 */
|
||||
spinning?: boolean | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册loading指令
|
||||
* @param app
|
||||
* @param params
|
||||
*/
|
||||
export function registerLoadingDirective(
|
||||
app: App,
|
||||
params?: loadingDirectiveParams,
|
||||
) {
|
||||
// 注入一个样式供指令使用,确保容器是相对定位
|
||||
const style = document.createElement('style');
|
||||
style.id = CLASS_NAME_RELATIVE;
|
||||
style.innerHTML = `
|
||||
.${CLASS_NAME_RELATIVE} {
|
||||
position: relative !important;
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
if (params?.loading !== false) {
|
||||
app.directive(
|
||||
isString(params?.loading) ? params.loading : 'loading',
|
||||
loadingDirective,
|
||||
);
|
||||
}
|
||||
if (params?.spinning !== false) {
|
||||
app.directive(
|
||||
isString(params?.spinning) ? params.spinning : 'spinning',
|
||||
spinningDirective,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './directive';
|
||||
export { default as Loading } from './loading.vue';
|
||||
export { default as Spinner } from './spinner.vue';
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { VbenLoading } from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface LoadingProps {
|
||||
class?: string;
|
||||
/**
|
||||
* @zh_CN 最小加载时间
|
||||
* @en_US Minimum loading time
|
||||
*/
|
||||
minLoadingTime?: number;
|
||||
|
||||
/**
|
||||
* @zh_CN loading状态开启
|
||||
*/
|
||||
spinning?: boolean;
|
||||
/**
|
||||
* @zh_CN 文字
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'Loading' });
|
||||
const props = defineProps<LoadingProps>();
|
||||
</script>
|
||||
<template>
|
||||
<div :class="cn('relative min-h-20', props.class)">
|
||||
<slot></slot>
|
||||
<VbenLoading
|
||||
:min-loading-time="props.minLoadingTime"
|
||||
:spinning="props.spinning"
|
||||
:text="props.text"
|
||||
>
|
||||
<template v-if="$slots.icon" #icon>
|
||||
<slot name="icon"></slot>
|
||||
</template>
|
||||
</VbenLoading>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import { VbenSpinner } from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface SpinnerProps {
|
||||
class?: string;
|
||||
/**
|
||||
* @zh_CN 最小加载时间
|
||||
* @en_US Minimum loading time
|
||||
*/
|
||||
minLoadingTime?: number;
|
||||
/**
|
||||
* @zh_CN loading状态开启
|
||||
*/
|
||||
spinning?: boolean;
|
||||
}
|
||||
defineOptions({ name: 'Spinner' });
|
||||
const props = defineProps<SpinnerProps>();
|
||||
</script>
|
||||
<template>
|
||||
<div :class="cn('relative min-h-20', props.class)">
|
||||
<slot></slot>
|
||||
<VbenSpinner
|
||||
:min-loading-time="props.minLoadingTime"
|
||||
:spinning="props.spinning"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Page } from '..';
|
||||
|
||||
describe('page.vue', () => {
|
||||
it('renders title when passed', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
title: 'Test Title',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Test Title');
|
||||
});
|
||||
|
||||
it('renders description when passed', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
description: 'Test Description',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Test Description');
|
||||
});
|
||||
|
||||
it('renders default slot content', () => {
|
||||
const wrapper = mount(Page, {
|
||||
slots: {
|
||||
default: '<p>Default Slot Content</p>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('<p>Default Slot Content</p>');
|
||||
});
|
||||
|
||||
it('renders footer slot when showFooter is true', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
showFooter: true,
|
||||
},
|
||||
slots: {
|
||||
footer: '<p>Footer Slot Content</p>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('<p>Footer Slot Content</p>');
|
||||
});
|
||||
|
||||
it('applies the custom contentClass', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
contentClass: 'custom-class',
|
||||
},
|
||||
});
|
||||
|
||||
const contentDiv = wrapper.find('.p-4');
|
||||
expect(contentDiv.classes()).toContain('custom-class');
|
||||
});
|
||||
|
||||
it('does not render title slot if title prop is provided', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
title: 'Test Title',
|
||||
},
|
||||
slots: {
|
||||
title: '<p>Title Slot Content</p>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Title Slot Content');
|
||||
expect(wrapper.html()).not.toContain('Test Title');
|
||||
});
|
||||
|
||||
it('does not render description slot if description prop is provided', () => {
|
||||
const wrapper = mount(Page, {
|
||||
props: {
|
||||
description: 'Test Description',
|
||||
},
|
||||
slots: {
|
||||
description: '<p>Description Slot Content</p>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Description Slot Content');
|
||||
expect(wrapper.html()).not.toContain('Test Description');
|
||||
});
|
||||
});
|
||||
2
packages/effects/common-ui/src/components/page/index.ts
Normal file
2
packages/effects/common-ui/src/components/page/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Page } from './page.vue';
|
||||
export * from './types';
|
||||
88
packages/effects/common-ui/src/components/page/page.vue
Normal file
88
packages/effects/common-ui/src/components/page/page.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<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 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="relative flex min-h-full flex-col">
|
||||
<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>
|
||||
17
packages/effects/common-ui/src/components/page/types.ts
Normal file
17
packages/effects/common-ui/src/components/page/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface PageProps {
|
||||
title?: string;
|
||||
description?: 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 @@
|
||||
export { default as VResize } from './resize.vue';
|
||||
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
100
packages/effects/common-ui/src/components/tippy/directive.ts
Normal file
100
packages/effects/common-ui/src/components/tippy/directive.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ComputedRef, Directive } from 'vue';
|
||||
|
||||
import { useTippy } from 'vue-tippy';
|
||||
|
||||
export default function useTippyDirective(isDark: ComputedRef<boolean>) {
|
||||
const directive: Directive = {
|
||||
mounted(el, binding, vnode) {
|
||||
const opts =
|
||||
typeof binding.value === 'string'
|
||||
? { content: binding.value }
|
||||
: binding.value || {};
|
||||
|
||||
const modifiers = Object.keys(binding.modifiers || {});
|
||||
const placement = modifiers.find((modifier) => modifier !== 'arrow');
|
||||
const withArrow = modifiers.includes('arrow');
|
||||
|
||||
if (placement) {
|
||||
opts.placement = opts.placement || placement;
|
||||
}
|
||||
|
||||
if (withArrow) {
|
||||
opts.arrow = opts.arrow === undefined ? true : opts.arrow;
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyShow) {
|
||||
opts.onShow = function (...args: any[]) {
|
||||
return vnode.props?.onTippyShow(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyShown) {
|
||||
opts.onShown = function (...args: any[]) {
|
||||
return vnode.props?.onTippyShown(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyHidden) {
|
||||
opts.onHidden = function (...args: any[]) {
|
||||
return vnode.props?.onTippyHidden(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyHide) {
|
||||
opts.onHide = function (...args: any[]) {
|
||||
return vnode.props?.onTippyHide(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyMount) {
|
||||
opts.onMount = function (...args: any[]) {
|
||||
return vnode.props?.onTippyMount(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (el.getAttribute('title') && !opts.content) {
|
||||
opts.content = el.getAttribute('title');
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
|
||||
if (el.getAttribute('content') && !opts.content) {
|
||||
opts.content = el.getAttribute('content');
|
||||
}
|
||||
|
||||
useTippy(el, opts);
|
||||
},
|
||||
unmounted(el) {
|
||||
if (el.$tippy) {
|
||||
el.$tippy.destroy();
|
||||
} else if (el._tippy) {
|
||||
el._tippy.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const opts =
|
||||
typeof binding.value === 'string'
|
||||
? { content: binding.value, theme: isDark.value ? '' : 'light' }
|
||||
: Object.assign(
|
||||
{ theme: isDark.value ? '' : 'light' },
|
||||
binding.value,
|
||||
);
|
||||
|
||||
if (el.getAttribute('title') && !opts.content) {
|
||||
opts.content = el.getAttribute('title');
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
|
||||
if (el.getAttribute('content') && !opts.content) {
|
||||
opts.content = el.getAttribute('content');
|
||||
}
|
||||
|
||||
if (el.$tippy) {
|
||||
el.$tippy.setProps(opts || {});
|
||||
} else if (el._tippy) {
|
||||
el._tippy.setProps(opts || {});
|
||||
}
|
||||
},
|
||||
};
|
||||
return directive;
|
||||
}
|
||||
67
packages/effects/common-ui/src/components/tippy/index.ts
Normal file
67
packages/effects/common-ui/src/components/tippy/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { DefaultProps, Props } from 'tippy.js';
|
||||
|
||||
import type { App, SetupContext } from 'vue';
|
||||
|
||||
import { h, watchEffect } from 'vue';
|
||||
import { setDefaultProps, Tippy as TippyComponent } from 'vue-tippy';
|
||||
|
||||
import { usePreferences } from '@vben-core/preferences';
|
||||
|
||||
import useTippyDirective from './directive';
|
||||
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
import 'tippy.js/dist/backdrop.css';
|
||||
import 'tippy.js/themes/light.css';
|
||||
import 'tippy.js/animations/scale.css';
|
||||
import 'tippy.js/animations/shift-toward.css';
|
||||
import 'tippy.js/animations/shift-away.css';
|
||||
import 'tippy.js/animations/perspective.css';
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
export type TippyProps = Partial<
|
||||
Props & {
|
||||
animation?:
|
||||
| 'fade'
|
||||
| 'perspective'
|
||||
| 'scale'
|
||||
| 'shift-away'
|
||||
| 'shift-toward'
|
||||
| boolean;
|
||||
theme?: 'auto' | 'dark' | 'light';
|
||||
}
|
||||
>;
|
||||
|
||||
export function initTippy(app: App<Element>, options?: DefaultProps) {
|
||||
setDefaultProps({
|
||||
allowHTML: true,
|
||||
delay: [500, 200],
|
||||
theme: isDark.value ? '' : 'light',
|
||||
...options,
|
||||
});
|
||||
if (!options || !Reflect.has(options, 'theme') || options.theme === 'auto') {
|
||||
watchEffect(() => {
|
||||
setDefaultProps({ theme: isDark.value ? '' : 'light' });
|
||||
});
|
||||
}
|
||||
|
||||
app.directive('tippy', useTippyDirective(isDark));
|
||||
}
|
||||
|
||||
export const Tippy = (props: any, { attrs, slots }: SetupContext) => {
|
||||
let theme: string = (attrs.theme as string) ?? 'auto';
|
||||
if (theme === 'auto') {
|
||||
theme = isDark.value ? '' : 'light';
|
||||
}
|
||||
if (theme === 'dark') {
|
||||
theme = '';
|
||||
}
|
||||
return h(
|
||||
TippyComponent,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
theme,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
};
|
||||
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>
|
||||
Reference in New Issue
Block a user