Initial commit: OneOS frontend based on yudao-ui-admin-vben
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

This commit is contained in:
k kfluous
2026-03-11 22:18:23 +08:00
commit 2650d1cdf0
5166 changed files with 641242 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import type { CaptchaPoint } from '../types';
import { reactive } from 'vue';
export function useCaptchaPoints() {
const points = reactive<CaptchaPoint[]>([]);
function addPoint(point: CaptchaPoint) {
points.push(point);
}
function clearPoints() {
points.splice(0);
}
return {
addPoint,
clearPoints,
points,
};
}

View File

@@ -0,0 +1,9 @@
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';
export { default as Verification } from './verification/index.vue';

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import type { CaptchaPoint, PointSelectionCaptchaProps } from '../types';
import { RotateCw } from '@vben/icons';
import { $t } from '@vben/locales';
import { VbenButton, VbenIconButton } from '@vben-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>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { PointSelectionCaptchaCardProps } from '../types';
import { computed } from 'vue';
import { $t } from '@vben/locales';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@vben-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>

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import type {
CaptchaVerifyPassingData,
SliderCaptchaProps,
SliderRotateVerifyPassingData,
} from '../types';
import { reactive, unref, useTemplateRef, watch, watchEffect } from 'vue';
import { $t } from '@vben/locales';
import { cn } from '@vben-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>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import { Check, ChevronsRight } from '@vben/icons';
import { Slot } from '@vben-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>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
const props = defineProps<{
barStyle: CSSProperties;
toLeft: boolean;
}>();
const barRef = useTemplateRef<HTMLDivElement>('barRef');
const width = ref('0');
const style = computed(() => {
const { barStyle } = props;
return {
...barStyle,
width: width.value,
};
});
defineExpose({
getEl: () => {
return barRef.value;
},
setWidth: (val: string) => {
width.value = val;
},
});
</script>
<template>
<div
ref="barRef"
:class="toLeft && 'transition-width !w-0 duration-300'"
:style="style"
class="bg-success absolute h-full"
></div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, useTemplateRef } from 'vue';
import { VbenSpineText } from '@vben-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>

View File

@@ -0,0 +1,213 @@
<script setup lang="ts">
import type {
CaptchaVerifyPassingData,
SliderCaptchaActionType,
SliderRotateCaptchaProps,
SliderRotateVerifyPassingData,
} from '../types';
import { computed, reactive, unref, useTemplateRef, watch } from 'vue';
import { $t } from '@vben/locales';
import { useTimeoutFn } from '@vueuse/core';
import SliderCaptcha from '../slider-captcha/index.vue';
const props = withDefaults(defineProps<SliderRotateCaptchaProps>(), {
defaultTip: '',
diffDegree: 20,
imageSize: 260,
maxDegree: 300,
minDegree: 120,
src: '',
});
const emit = defineEmits<{
success: [CaptchaVerifyPassingData];
}>();
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
const state = reactive({
currentRotate: 0,
dragging: false,
endTime: 0,
imgStyle: {},
isPassing: false,
randomRotate: 0,
showTip: false,
startTime: 0,
toOrigin: false,
});
const modalValue = defineModel<boolean>({ default: false });
watch(
() => state.isPassing,
(isPassing) => {
if (isPassing) {
const { endTime, startTime } = state;
const time = (endTime - startTime) / 1000;
emit('success', { isPassing, time: time.toFixed(1) });
}
modalValue.value = isPassing;
},
);
const getImgWrapStyleRef = computed(() => {
const { imageSize, imageWrapperStyle } = props;
return {
height: `${imageSize}px`,
width: `${imageSize}px`,
...imageWrapperStyle,
};
});
const getFactorRef = computed(() => {
const { maxDegree, minDegree } = props;
if (minDegree > maxDegree) {
console.warn('minDegree should not be greater than maxDegree');
}
if (minDegree === maxDegree) {
return Math.floor(1 + Math.random() * 1) / 10 + 1;
}
return 1;
});
function handleStart() {
state.startTime = Date.now();
}
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
state.dragging = true;
const { imageSize, maxDegree } = props;
const { moveX } = data;
const denominator = imageSize!;
if (denominator === 0) {
return;
}
const currentRotate = Math.ceil(
(moveX / denominator) * 1.5 * maxDegree! * unref(getFactorRef),
);
state.currentRotate = currentRotate;
setImgRotate(state.randomRotate - currentRotate);
}
function handleImgOnLoad() {
const { maxDegree, minDegree } = props;
const ranRotate = Math.floor(
minDegree! + Math.random() * (maxDegree! - minDegree!),
); // 生成随机角度
state.randomRotate = ranRotate;
setImgRotate(ranRotate);
}
function handleDragEnd() {
const { currentRotate, randomRotate } = state;
const { diffDegree } = props;
if (Math.abs(randomRotate - currentRotate) >= (diffDegree || 20)) {
setImgRotate(randomRotate);
state.toOrigin = true;
useTimeoutFn(() => {
state.toOrigin = false;
state.showTip = true;
// 时间与动画时间保持一致
}, 300);
} else {
checkPass();
}
state.showTip = true;
state.dragging = false;
}
function setImgRotate(deg: number) {
state.imgStyle = {
transform: `rotateZ(${deg}deg)`,
};
}
function checkPass() {
state.isPassing = true;
state.endTime = Date.now();
}
function resume() {
state.showTip = false;
const basicEl = unref(slideBarRef);
if (!basicEl) {
return;
}
state.isPassing = false;
basicEl.resume();
handleImgOnLoad();
}
const imgCls = computed(() => {
return state.toOrigin ? ['transition-transform duration-300'] : [];
});
const verifyTip = computed(() => {
return state.isPassing
? $t('ui.captcha.sliderRotateSuccessTip', [
((state.endTime - state.startTime) / 1000).toFixed(1),
])
: $t('ui.captcha.sliderRotateFailTip');
});
defineExpose({
resume,
});
</script>
<template>
<div class="relative flex flex-col items-center">
<div
:style="getImgWrapStyleRef"
class="border-border relative cursor-pointer overflow-hidden rounded-full border shadow-md"
>
<img
:class="imgCls"
:src="src"
:style="state.imgStyle"
alt="verify"
class="w-full rounded-full"
@click="resume"
@load="handleImgOnLoad"
/>
<div
class="absolute bottom-3 left-0 z-10 block h-7 w-full text-center text-xs leading-[30px] text-white"
>
<div
v-if="state.showTip"
:class="{
'bg-success/80': state.isPassing,
'bg-destructive/80': !state.isPassing,
}"
>
{{ verifyTip }}
</div>
<div v-if="!state.dragging" class="bg-black/30">
{{ defaultTip || $t('ui.captcha.sliderRotateDefaultTip') }}
</div>
</div>
</div>
<SliderCaptcha
ref="slideBarRef"
v-model="modalValue"
class="mt-5"
is-slot
@end="handleDragEnd"
@move="handleDragBarMove"
@start="handleStart"
>
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
<slot :name="key" v-bind="slotProps"></slot>
</template>
</SliderCaptcha>
</div>
</template>

View File

@@ -0,0 +1,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>

View File

@@ -0,0 +1,210 @@
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;
}

View File

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

View File

@@ -0,0 +1,25 @@
interface VerificationProps {
arith?: number;
barSize?: {
height: string;
width: string;
};
blockSize?: {
height: string;
width: string;
};
captchaType?: 'blockPuzzle' | 'clickWord';
explain?: string;
figure?: number;
imgSize?: {
height: string;
width: string;
};
mode?: 'fixed' | 'pop';
space?: number;
type?: '1' | '2';
checkCaptchaApi?: (data: any) => Promise<any>;
getCaptchaApi?: (data: any) => Promise<any>;
}
export type { VerificationProps };

View File

@@ -0,0 +1,102 @@
export function resetSize(vm: any) {
const EmployeeWindow = window as any;
const parentWidth =
vm.$el.parentNode.offsetWidth || EmployeeWindow.offsetWidth;
const parentHeight =
vm.$el.parentNode.offsetHeight || EmployeeWindow.offsetHeight;
const img_width = vm.imgSize.width.includes('%')
? `${(Number.parseInt(vm.imgSize.width) / 100) * parentWidth}px`
: vm.imgSize.width;
const img_height = vm.imgSize.height.includes('%')
? `${(Number.parseInt(vm.imgSize.height) / 100) * parentHeight}px`
: vm.imgSize.height;
const bar_width = vm.barSize.width.includes('%')
? `${(Number.parseInt(vm.barSize.width) / 100) * parentWidth}px`
: vm.barSize.width;
const bar_height = vm.barSize.height.includes('%')
? `${(Number.parseInt(vm.barSize.height) / 100) * parentHeight}px`
: vm.barSize.height;
return {
barHeight: bar_height,
barWidth: bar_width,
imgHeight: img_height,
imgWidth: img_width,
};
}
export const _code_chars = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
];
export const _code_color1 = ['#fffff0', '#f0ffff', '#f0fff0', '#fff0f0'];
export const _code_color2 = [
'#FF0033',
'#006699',
'#993366',
'#FF9900',
'#66CC66',
'#FF33CC',
];

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -0,0 +1,26 @@
import type { PageProps } from '../page/types';
export interface ColPageProps extends PageProps {
/**
* 左侧宽度
* @default 30
*/
leftWidth?: number;
leftMinWidth?: number;
leftMaxWidth?: number;
leftCollapsedWidth?: number;
leftCollapsible?: boolean;
/**
* 右侧宽度
* @default 70
*/
rightWidth?: number;
rightMinWidth?: number;
rightCollapsedWidth?: number;
rightMaxWidth?: number;
rightCollapsible?: boolean;
resizable?: boolean;
splitLine?: boolean;
splitHandle?: boolean;
}

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import type { StyleValue } from 'vue';
import type { ContentWrapProps } from './types';
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT } from '@vben-core/shared/constants';
import { cn } from '@vben-core/shared/utils';
defineOptions({
name: 'ContentWrap',
});
const { autoContentHeight = false, heightOffset = 0 } =
defineProps<ContentWrapProps>();
const headerHeight = ref(0);
const footerHeight = ref(0);
const shouldAutoHeight = ref(false);
const headerRef = useTemplateRef<HTMLDivElement>('headerRef');
const footerRef = useTemplateRef<HTMLDivElement>('footerRef');
const contentStyle = computed<StyleValue>(() => {
if (autoContentHeight) {
return {
height: `calc(var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT}) - ${headerHeight.value}px - ${footerHeight.value}px - ${typeof heightOffset === 'number' ? `${heightOffset}px` : heightOffset})`,
overflowY: shouldAutoHeight.value ? 'auto' : 'unset',
};
}
return {};
});
async function calcContentHeight() {
if (!autoContentHeight) {
return;
}
await nextTick();
headerHeight.value = headerRef.value?.offsetHeight || 0;
footerHeight.value = footerRef.value?.offsetHeight || 0;
setTimeout(() => {
shouldAutoHeight.value = true;
}, 30);
}
onMounted(() => {
calcContentHeight();
});
</script>
<template>
<div
class="bg-card text-card-foreground border-border relative flex min-h-full flex-col rounded-xl border"
>
<div
v-if="
description ||
$slots.description ||
title ||
$slots.title ||
$slots.extra
"
ref="headerRef"
:class="
cn(
'border-border relative flex items-end border-b px-6 py-4',
headerClass,
)
"
>
<div class="flex-auto">
<slot name="title">
<div v-if="title" class="mb-2 flex text-lg font-semibold">
{{ title }}
</div>
<div v-if="$slots.extra" class="flex justify-end">
<slot name="extra"></slot>
</div>
</slot>
<slot name="description">
<p v-if="description" class="text-muted-foreground">
{{ description }}
</p>
</slot>
</div>
</div>
<div :class="cn('h-full p-4', contentClass)" :style="contentStyle">
<slot></slot>
</div>
<div
v-if="$slots.footer"
ref="footerRef"
:class="cn('align-center flex px-6 py-4', footerClass)"
>
<slot name="footer"></slot>
</div>
</div>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -0,0 +1,53 @@
import type { CubicBezierPoints, EasingFunction } from '@vueuse/core';
import type { StyleValue } from 'vue';
import { TransitionPresets as TransitionPresetsData } from '@vueuse/core';
export type TransitionPresets = keyof typeof TransitionPresetsData;
export const TransitionPresetsKeys = Object.keys(
TransitionPresetsData,
) as TransitionPresets[];
export interface CountToProps {
/** 初始值 */
startVal?: number;
/** 当前值 */
endVal: number;
/** 是否禁用动画 */
disabled?: boolean;
/** 延迟动画开始的时间 */
delay?: number;
/** 持续时间 */
duration?: number;
/** 小数位数 */
decimals?: number;
/** 小数点 */
decimal?: string;
/** 分隔符 */
separator?: string;
/** 前缀 */
prefix?: string;
/** 后缀 */
suffix?: string;
/** 过渡效果 */
transition?: CubicBezierPoints | EasingFunction | TransitionPresets;
/** 整数部分的类名 */
mainClass?: string;
/** 小数部分的类名 */
decimalClass?: string;
/** 前缀部分的类名 */
prefixClass?: string;
/** 后缀部分的类名 */
suffixClass?: string;
/** 整数部分的样式 */
mainStyle?: StyleValue;
/** 小数部分的样式 */
decimalStyle?: StyleValue;
/** 前缀部分的样式 */
prefixStyle?: StyleValue;
/** 后缀部分的样式 */
suffixStyle?: StyleValue;
}

View File

@@ -0,0 +1,979 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue';
// 定义组件参数
const props = defineProps<{
/** 裁剪比例 格式如 '1:1', '16:9', '3:4' 等(非必填) */
aspectRatio?: string;
/** 容器高度默认400 */
height?: number;
/** 图片地址 */
img: string;
/** 容器宽度默认500 */
width?: number;
}>();
const CROPPER_CONSTANTS = {
MIN_WIDTH: 60 as const,
MIN_HEIGHT: 60 as const,
DEFAULT_WIDTH: 500 as const,
DEFAULT_HEIGHT: 400 as const,
PADDING_RATIO: 0.1 as const,
MAX_PADDING: 50 as const,
} as const;
type Point = [number, number]; // [clientX, clientY]
type Dimension = [number, number, number, number]; // [top, right, bottom, left]
// 拖拽点类型
type DragAction =
| 'bottom'
| 'bottom-left'
| 'bottom-right'
| 'left'
| 'move'
| 'right'
| 'top'
| 'top-left'
| 'top-right';
// DOM 引用
const containerRef = ref<HTMLDivElement | null>(null);
const bgImageRef = ref<HTMLImageElement | null>(null);
// const maskRef = ref<HTMLDivElement | null>(null);
const maskViewRef = ref<HTMLDivElement | null>(null);
const cropperRef = ref<HTMLDivElement | null>(null);
// const cropperViewRef = ref<HTMLDivElement | null>(null);
// 响应式数据
const isCropperVisible = ref<boolean>(false);
const validAspectRatio = ref<null | number>(null); // 有效比例值null表示无固定比例
const containerWidth = ref<number>(
props.width ?? CROPPER_CONSTANTS.DEFAULT_WIDTH,
);
const containerHeight = ref<number>(
props.height ?? CROPPER_CONSTANTS.DEFAULT_HEIGHT,
);
// 裁剪区域尺寸top, right, bottom, left
const currentDimension = ref<Dimension>([50, 50, 50, 50]);
const initDimension = ref<Dimension>([50, 50, 50, 50]);
// 拖拽状态
const dragging = ref<boolean>(false);
const startPoint = ref<Point>([0, 0]);
const startDimension = ref<Dimension>([0, 0, 0, 0]);
const direction = ref<Dimension>([0, 0, 0, 0]);
const moving = ref<boolean>(false);
/**
* 计算图片的适配尺寸,保证完整显示且不超过最大宽高限制
*/
const calculateImageFitSize = () => {
if (!bgImageRef.value) return;
// 获取图片原始尺寸
const imgWidth = bgImageRef.value.naturalWidth;
const imgHeight = bgImageRef.value.naturalHeight;
if (imgWidth === 0 || imgHeight === 0) return;
// 计算缩放比例使用传入的width/height默认500/400
const widthRatio =
(props.width ?? CROPPER_CONSTANTS.DEFAULT_WIDTH) / imgWidth;
const heightRatio =
(props.height ?? CROPPER_CONSTANTS.DEFAULT_HEIGHT) / imgHeight;
const scaleRatio = Math.min(widthRatio, heightRatio, 1); // 不放大图片,只缩小
// 计算适配后的容器尺寸
const fitWidth = Math.floor(imgWidth * scaleRatio);
const fitHeight = Math.floor(imgHeight * scaleRatio);
containerWidth.value = fitWidth;
containerHeight.value = fitHeight;
// 重置裁剪框初始尺寸(基于新的容器尺寸)
const padding = Math.min(
CROPPER_CONSTANTS.MAX_PADDING,
Math.floor(fitWidth * CROPPER_CONSTANTS.PADDING_RATIO),
Math.floor(fitHeight * CROPPER_CONSTANTS.PADDING_RATIO),
);
initDimension.value = [padding, padding, padding, padding];
currentDimension.value = [padding, padding, padding, padding];
};
/**
* 验证并解析比例字符串
* @returns {number|null} 比例值 (width/height)解析失败返回null
*/
const parseAndValidateAspectRatio = (): null | number => {
// 如果未传入比例参数直接返回null
if (!props.aspectRatio) {
return null;
}
// 验证比例格式
const ratioRegex = /^[1-9]\d*:[1-9]\d*$/;
if (!ratioRegex.test(props.aspectRatio)) {
console.warn('裁剪比例格式错误,应为 "数字:数字" 格式,如 "16:9"');
return null;
}
// 解析比例
const [width, height] = props.aspectRatio.split(':').map(Number);
// 验证解析结果有效性
if (Number.isNaN(width) || Number.isNaN(height) || !width || !height) {
console.warn('裁剪比例解析失败,宽高必须为正整数');
return null;
}
return width / height;
};
/**
* 设置裁剪区域尺寸
* @param {Dimension} dimension - [top, right, bottom, left]
*/
const setDimension = (dimension: Dimension) => {
currentDimension.value = [...dimension];
if (maskViewRef.value) {
maskViewRef.value.style.clipPath = `inset(${dimension[0]}px ${dimension[1]}px ${dimension[2]}px ${dimension[3]}px)`;
}
};
/**
* 调整裁剪区域至指定比例
*/
const adjustCropperToAspectRatio = () => {
if (!cropperRef.value) return;
// 验证并解析比例
validAspectRatio.value = parseAndValidateAspectRatio();
// 如果无有效比例,使用初始尺寸,不强制固定比例
if (validAspectRatio.value === null) {
setDimension(initDimension.value);
return;
}
// 有有效比例,按比例调整裁剪框
const ratio = validAspectRatio.value;
const containerWidthVal = containerWidth.value;
const containerHeightVal = containerHeight.value;
// 根据比例计算裁剪框尺寸
let newHeight: number, newWidth: number;
// 先按宽度优先计算
newWidth = containerWidthVal;
newHeight = newWidth / ratio;
// 如果高度超出容器,按高度优先计算
if (newHeight > containerHeightVal) {
newHeight = containerHeightVal;
newWidth = newHeight * ratio;
}
// 居中显示
const leftRight = (containerWidthVal - newWidth) / 2;
const topBottom = (containerHeightVal - newHeight) / 2;
const newDimension: Dimension = [topBottom, leftRight, topBottom, leftRight];
setDimension(newDimension);
};
/**
* 创建裁剪器
*/
const createCropper = () => {
// 计算图片适配尺寸
calculateImageFitSize();
isCropperVisible.value = true;
adjustCropperToAspectRatio();
};
/**
* 处理鼠标按下事件
* @param {MouseEvent} e - 鼠标事件
* @param {DragAction} action - 操作类型
*/
const handleMouseDown = (e: MouseEvent, action: DragAction) => {
dragging.value = true;
startPoint.value = [e.clientX, e.clientY];
startDimension.value = [...currentDimension.value];
direction.value = [0, 0, 0, 0];
moving.value = false;
// 处理移动
if (action === 'move') {
direction.value[0] = 1;
direction.value[2] = -1;
direction.value[3] = 1;
direction.value[1] = -1;
moving.value = true;
return;
}
// 处理拖拽方向
switch (action) {
case 'bottom': {
direction.value[2] = -1;
break;
}
case 'bottom-left': {
direction.value[2] = -1;
direction.value[3] = 1;
break;
}
case 'bottom-right': {
direction.value[2] = -1;
direction.value[1] = -1;
break;
}
case 'left': {
direction.value[3] = 1;
break;
}
case 'right': {
direction.value[1] = -1;
break;
}
case 'top': {
direction.value[0] = 1;
break;
}
case 'top-left': {
direction.value[0] = 1;
direction.value[3] = 1;
break;
}
case 'top-right': {
direction.value[0] = 1;
direction.value[1] = -1;
break;
}
}
};
/**
* 处理鼠标移动事件
* @param {MouseEvent} e - 鼠标事件
*/
const handleMouseMove = (e: MouseEvent) => {
if (!dragging.value || !cropperRef.value) return;
const { clientX, clientY } = e;
const diffX = clientX - startPoint.value[0];
const diffY = clientY - startPoint.value[1];
// 处理移动裁剪框
if (moving.value) {
handleMoveCropBox(diffX, diffY);
return;
}
// 无有效比例
if (validAspectRatio.value === null) {
handleFreeAspectResize(diffX, diffY);
} else {
handleFixedAspectResize(diffX, diffY);
}
};
const handleMoveCropBox = (diffX: number, diffY: number) => {
const newDimension = [...startDimension.value] as Dimension;
// 计算临时偏移后的位置
const tempTop = startDimension.value[0] + diffY;
const tempLeft = startDimension.value[3] + diffX;
// 计算裁剪框的固定尺寸
const cropWidth =
containerWidth.value - startDimension.value[3] - startDimension.value[1];
const cropHeight =
containerHeight.value - startDimension.value[0] - startDimension.value[2];
// 边界限制:确保裁剪框完全在容器内,且尺寸不变
// 顶部边界top >= 0且 bottom = 容器高度 - top - 裁剪高度 >= 0
newDimension[0] = Math.max(
0,
Math.min(tempTop, containerHeight.value - cropHeight),
);
// 底部边界bottom = 容器高度 - top - 裁剪高度由top推导无需额外计算
newDimension[2] = containerHeight.value - newDimension[0] - cropHeight;
// 左侧边界left >= 0且 right = 容器宽度 - left - 裁剪宽度 >= 0
newDimension[3] = Math.max(
0,
Math.min(tempLeft, containerWidth.value - cropWidth),
);
// 右侧边界right = 容器宽度 - left - 裁剪宽度由left推导无需额外计算
newDimension[1] = containerWidth.value - newDimension[3] - cropWidth;
// 强制保证尺寸不变(兜底)
const finalWidth = containerWidth.value - newDimension[3] - newDimension[1];
const finalHeight = containerHeight.value - newDimension[0] - newDimension[2];
if (finalWidth !== cropWidth) {
newDimension[1] = containerWidth.value - newDimension[3] - cropWidth;
}
if (finalHeight !== cropHeight) {
newDimension[2] = containerHeight.value - newDimension[0] - cropHeight;
}
// 更新裁剪区域(仅位置变化,尺寸/比例完全不变)
setDimension(newDimension);
};
const handleFreeAspectResize = (diffX: number, diffY: number) => {
const cropperWidth = containerWidth.value;
const cropperHeight = containerHeight.value;
const currentDimensionNew: Dimension = [0, 0, 0, 0];
// 计算新的尺寸,确保不小于最小值
currentDimensionNew[0] = Math.min(
Math.max(startDimension.value[0] + direction.value[0] * diffY, 0),
cropperHeight - CROPPER_CONSTANTS.MIN_HEIGHT,
);
currentDimensionNew[1] = Math.min(
Math.max(startDimension.value[1] + direction.value[1] * diffX, 0),
cropperWidth - CROPPER_CONSTANTS.MIN_WIDTH,
);
currentDimensionNew[2] = Math.min(
Math.max(startDimension.value[2] + direction.value[2] * diffY, 0),
cropperHeight - CROPPER_CONSTANTS.MIN_HEIGHT,
);
currentDimensionNew[3] = Math.min(
Math.max(startDimension.value[3] + direction.value[3] * diffX, 0),
cropperWidth - CROPPER_CONSTANTS.MIN_WIDTH,
);
// 确保裁剪区域宽度和高度不小于最小值
const newWidth =
cropperWidth - currentDimensionNew[3] - currentDimensionNew[1];
const newHeight =
cropperHeight - currentDimensionNew[0] - currentDimensionNew[2];
if (newWidth < CROPPER_CONSTANTS.MIN_WIDTH) {
if (direction.value[3] === 1) {
currentDimensionNew[3] =
cropperWidth - currentDimensionNew[1] - CROPPER_CONSTANTS.MIN_WIDTH;
} else {
currentDimensionNew[1] =
cropperWidth - currentDimensionNew[3] - CROPPER_CONSTANTS.MIN_WIDTH;
}
}
if (newHeight < CROPPER_CONSTANTS.MIN_HEIGHT) {
if (direction.value[0] === 1) {
currentDimensionNew[0] =
cropperHeight - currentDimensionNew[2] - CROPPER_CONSTANTS.MIN_HEIGHT;
} else {
currentDimensionNew[2] =
cropperHeight - currentDimensionNew[0] - CROPPER_CONSTANTS.MIN_HEIGHT;
}
}
setDimension(currentDimensionNew);
};
const handleFixedAspectResize = (diffX: number, diffY: number) => {
if (validAspectRatio.value === null) return;
const cropperWidth = containerWidth.value;
const cropperHeight = containerHeight.value;
// 有有效比例 - 固定比例裁剪
const ratio = validAspectRatio.value;
const currentWidth =
cropperWidth - startDimension.value[3] - startDimension.value[1];
const currentHeight =
cropperHeight - startDimension.value[0] - startDimension.value[2];
let newHeight: number, newWidth: number;
let widthChange = 0;
let heightChange = 0;
// 计算宽度/高度变化量
if (direction.value[3] === 1) widthChange = -diffX;
else if (direction.value[1] === -1) widthChange = diffX;
if (direction.value[0] === 1) heightChange = -diffY;
else if (direction.value[2] === -1) heightChange = diffY;
const isCornerDrag =
(direction.value[3] === 1 || direction.value[1] === -1) &&
(direction.value[0] === 1 || direction.value[2] === -1);
// 计算新尺寸
if (isCornerDrag) {
if (Math.abs(widthChange) > Math.abs(heightChange)) {
newWidth = Math.max(
CROPPER_CONSTANTS.MIN_WIDTH,
currentWidth + widthChange,
);
newHeight = newWidth / ratio;
} else {
newHeight = Math.max(
CROPPER_CONSTANTS.MIN_HEIGHT,
currentHeight + heightChange,
);
newWidth = newHeight * ratio;
}
} else {
if (direction.value[3] === 1 || direction.value[1] === -1) {
newWidth = Math.max(
CROPPER_CONSTANTS.MIN_WIDTH,
currentWidth + widthChange,
);
newHeight = newWidth / ratio;
} else {
newHeight = Math.max(
CROPPER_CONSTANTS.MIN_HEIGHT,
currentHeight + heightChange,
);
newWidth = newHeight * ratio;
}
}
// 限制最大尺寸
const maxWidth = cropperWidth;
const maxHeight = cropperHeight;
if (newWidth > maxWidth) {
newWidth = maxWidth;
newHeight = newWidth / ratio;
}
if (newHeight > maxHeight) {
newHeight = maxHeight;
newWidth = newHeight * ratio;
}
// 计算新的位置
let newLeft = startDimension.value[3];
let newTop = startDimension.value[0];
let newRight = startDimension.value[1];
let newBottom = startDimension.value[2];
// 根据拖拽方向调整位置
if (direction.value[3] === 1) {
newLeft = cropperWidth - newWidth - startDimension.value[1];
} else if (direction.value[1] === -1) {
newRight = cropperWidth - newWidth - startDimension.value[3];
} else if (!isCornerDrag) {
// 居中调整
const currentHorizontalCenter = startDimension.value[3] + currentWidth / 2;
newLeft = Math.max(
0,
Math.min(cropperWidth - newWidth, currentHorizontalCenter - newWidth / 2),
);
newRight = cropperWidth - newWidth - newLeft;
}
if (direction.value[0] === 1) {
newTop = cropperHeight - newHeight - startDimension.value[2];
} else if (direction.value[2] === -1) {
newBottom = cropperHeight - newHeight - startDimension.value[0];
} else if (!isCornerDrag) {
// 居中调整
const currentVerticalCenter = startDimension.value[0] + currentHeight / 2;
newTop = Math.max(
0,
Math.min(
cropperHeight - newHeight,
currentVerticalCenter - newHeight / 2,
),
);
newBottom = cropperHeight - newHeight - newTop;
}
// 边界检查
newLeft = Math.max(0, newLeft);
newTop = Math.max(0, newTop);
newRight = Math.max(0, newRight);
newBottom = Math.max(0, newBottom);
const newDimension: Dimension = [newTop, newRight, newBottom, newLeft];
setDimension(newDimension);
};
/**
* 处理鼠标抬起事件
*/
const handleMouseUp = () => {
dragging.value = false;
moving.value = false;
direction.value = [0, 0, 0, 0];
};
/**
* 处理图片加载完成
*/
const handleImageLoad = () => {
createCropper();
};
/**
* 裁剪图片
* @param {'image/jpeg' | 'image/png'} format - 输出图片格式
* @param {number} quality - 压缩质量0-1
* @param {'blob' | 'base64'} outputType - 输出类型
* @param {number} targetWidth - 目标宽度(可选,不传则为原始裁剪宽度)
* @param {number} targetHeight - 目标高度(可选,不传则为原始裁剪高度)
*/
const getCropImage = async (
format: 'image/jpeg' | 'image/png' = 'image/jpeg',
quality: number = 0.92,
outputType: 'base64' | 'blob' = 'blob',
targetWidth?: number,
targetHeight?: number,
): Promise<Blob | string | undefined> => {
if (!props.img || !bgImageRef.value || !containerRef.value) return;
// 质量参数边界修正:强制限制在 0-1 区间,防止传入非法值报错
const validQuality = Math.max(0, Math.min(1, quality));
// 创建临时图片对象获取原始尺寸
const tempImg = new Image();
// 跨域图片处理:仅对非同源的网络图片设置跨域匿名
if (props.img.startsWith('http://') || props.img.startsWith('https://')) {
try {
const url = new URL(props.img);
if (url.origin !== location.origin) {
tempImg.crossOrigin = 'anonymous';
}
} catch {
// Invalid URL跳过跨域配置不中断执行
}
}
// 等待临时图片加载完成
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
tempImg.removeEventListener('load', handleLoad);
tempImg.removeEventListener('error', handleError);
reject(new Error('图片加载超时超时时间10秒'));
}, 10_000);
const handleLoad = () => {
clearTimeout(timeout);
tempImg.removeEventListener('load', handleLoad);
tempImg.removeEventListener('error', handleError);
resolve();
};
const handleError = (err: ErrorEvent) => {
clearTimeout(timeout);
tempImg.removeEventListener('load', handleLoad);
tempImg.removeEventListener('error', handleError);
reject(new Error(`图片加载失败: ${err.message}`));
};
tempImg.addEventListener('load', handleLoad);
tempImg.addEventListener('error', handleError);
tempImg.src = props.img;
});
const containerRect = containerRef.value.getBoundingClientRect();
const imgRect = bgImageRef.value.getBoundingClientRect();
// 1. 计算图片在容器内的渲染参数
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
const renderedImgWidth = imgRect.width;
const renderedImgHeight = imgRect.height;
const imgOffsetX = (containerWidth - renderedImgWidth) / 2;
const imgOffsetY = (containerHeight - renderedImgHeight) / 2;
// 2. 计算裁剪框在容器内的实际坐标
const [cropTop, cropRight, cropBottom, cropLeft] = currentDimension.value;
const cropBoxWidth = containerWidth - cropLeft - cropRight;
const cropBoxHeight = containerHeight - cropTop - cropBottom;
// 3. 将裁剪框坐标转换为图片上的坐标(考虑图片偏移)
const cropOnImgX = cropLeft - imgOffsetX;
const cropOnImgY = cropTop - imgOffsetY;
// 4. 计算渲染图片到原始图片的缩放比例(保留原始像素)
const scaleX = tempImg.width / renderedImgWidth;
const scaleY = tempImg.height / renderedImgHeight;
// 5. 映射到原始图片的裁剪区域(精确到原始像素,防止越界)
const originalCropX = Math.max(0, Math.floor(cropOnImgX * scaleX));
const originalCropY = Math.max(0, Math.floor(cropOnImgY * scaleY));
const originalCropWidth = Math.min(
Math.floor(cropBoxWidth * scaleX),
tempImg.width - originalCropX,
);
const originalCropHeight = Math.min(
Math.floor(cropBoxHeight * scaleY),
tempImg.height - originalCropY,
);
// 边界校验:裁剪尺寸非法则返回
if (originalCropWidth <= 0 || originalCropHeight <= 0) return;
// 6. 处理高清屏适配解决Retina屏模糊
const dpr = window.devicePixelRatio || 1;
// 最终画布尺寸(优先使用传入的目标尺寸,无则用原始裁剪尺寸)
const finalWidth = targetWidth ? Math.max(1, targetWidth) : originalCropWidth;
const finalHeight = targetHeight
? Math.max(1, targetHeight)
: originalCropHeight;
// 创建画布并获取绘制上下文
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 画布物理尺寸(乘以设备像素比,保证高清无模糊)
canvas.width = finalWidth * dpr;
canvas.height = finalHeight * dpr;
// 画布显示尺寸(视觉尺寸,和最终展示一致)
canvas.style.width = `${finalWidth}px`;
canvas.style.height = `${finalHeight}px`;
// 缩放画布上下文适配高清屏DPR
ctx.scale(dpr, dpr);
// 7. 绘制裁剪后的图片(使用原始像素绘制,保证清晰度)
ctx.drawImage(
tempImg,
originalCropX, // 原始图片裁剪起始X精确像素
originalCropY, // 原始图片裁剪起始Y精确像素
originalCropWidth, // 原始图片裁剪宽度(精确像素)
originalCropHeight, // 原始图片裁剪高度(精确像素)
0, // 画布绘制起始X
0, // 画布绘制起始Y
finalWidth, // 画布绘制宽度(目标尺寸)
finalHeight, // 画布绘制高度(目标尺寸)
);
try {
return outputType === 'base64'
? canvas.toDataURL(format, validQuality)
: new Promise<Blob>((resolve) => {
canvas.toBlob(
(blob) => {
// 兜底如果blob生成失败返回空Blob防止null
resolve(blob || new Blob([], { type: format }));
},
format,
validQuality,
);
});
} catch (error) {
console.error('图片导出失败:', error);
}
};
// 监听比例变化,重新调整裁剪框
watch(() => props.aspectRatio, adjustCropperToAspectRatio);
// 监听width/height变化重新计算尺寸
watch([() => props.width, () => props.height], () => {
calculateImageFitSize();
adjustCropperToAspectRatio();
});
// 组件挂载时注册全局事件
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// 如果图片已经加载完成,手动触发创建裁剪器
if (
bgImageRef.value &&
bgImageRef.value.complete &&
bgImageRef.value.naturalWidth > 0
) {
createCropper();
}
});
// 组件卸载时清理
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
});
defineExpose({ getCropImage });
</script>
<template>
<div
:style="{
width: `${width || CROPPER_CONSTANTS.DEFAULT_WIDTH}px`,
height: `${height || CROPPER_CONSTANTS.DEFAULT_HEIGHT}px`,
}"
class="cropper-action-wrapper"
>
<div
ref="containerRef"
class="cropper-container"
:style="{
width: `${containerWidth}px`,
height: `${containerHeight}px`,
}"
>
<!-- 原图展示 - 自适应尺寸 -->
<img
ref="bgImageRef"
class="cropper-image"
:src="img"
@load="handleImageLoad"
:style="{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}"
alt="裁剪原图"
/>
<!-- 遮罩层 -->
<div
class="cropper-mask"
:style="{
display: isCropperVisible ? 'block' : 'none',
width: '100%',
height: '100%',
}"
>
<div
ref="maskViewRef"
class="cropper-mask-view"
:style="{
backgroundImage: `url(${img})`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
clipPath: `inset(${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px)`,
width: '100%',
height: '100%',
}"
></div>
</div>
<!-- 裁剪框 -->
<div
ref="cropperRef"
class="cropper-box"
:style="{
display: isCropperVisible ? 'block' : 'none',
width: '100%',
height: '100%',
}"
>
<div
class="cropper-view"
:style="{
inset: `${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px`,
}"
>
<!-- 裁剪框辅助线-->
<span class="cropper-dashed-h"></span>
<span class="cropper-dashed-v"></span>
<!-- 裁剪框拖拽区域 -->
<span
class="cropper-move-area"
@mousedown="handleMouseDown($event, 'move')"
></span>
<!-- 边框线 -->
<span class="cropper-line-e"></span>
<span class="cropper-line-n"></span>
<span class="cropper-line-w"></span>
<span class="cropper-line-s"></span>
<!-- 边角拖拽点 -->
<span
class="cropper-point cropper-point-ne"
@mousedown="handleMouseDown($event, 'top-right')"
>
<span class="cropper-point-inner"></span>
</span>
<span
class="cropper-point cropper-point-nw"
@mousedown="handleMouseDown($event, 'top-left')"
>
<span class="cropper-point-inner"></span>
</span>
<span
class="cropper-point cropper-point-sw"
@mousedown="handleMouseDown($event, 'bottom-left')"
>
<span class="cropper-point-inner"></span>
</span>
<span
class="cropper-point cropper-point-se"
@mousedown="handleMouseDown($event, 'bottom-right')"
>
<span class="cropper-point-inner"></span>
</span>
<!-- 边中点拖拽点 -->
<span
class="cropper-point cropper-point-e"
@mousedown="handleMouseDown($event, 'right')"
>
<span class="cropper-point-inner"></span>
</span>
<span
class="cropper-point cropper-point-n"
@mousedown="handleMouseDown($event, 'top')"
>
<span class="cropper-point-inner"></span>
</span>
<span
class="cropper-point cropper-point-w"
@mousedown="handleMouseDown($event, 'left')"
>
<span class="cropper-point-inner"></span>
</span>
<span
class="cropper-point cropper-point-s"
@mousedown="handleMouseDown($event, 'bottom')"
>
<span class="cropper-point-inner"></span>
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.cropper-action-wrapper {
@apply box-border flex items-center justify-center;
background-color: transparent;
/* 马赛克背景 */
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-position:
0 0,
0 10px,
10px -10px,
-10px 0;
background-size: 20px 20px;
}
.cropper-container {
@apply relative;
}
.cropper-image {
@apply block;
}
/* 遮罩层 */
.cropper-mask {
@apply absolute left-0 top-0 bg-black/50;
}
.cropper-mask-view {
@apply absolute left-0 top-0;
}
/* 裁剪框 */
.cropper-box {
@apply absolute left-0 top-0 z-10;
}
.cropper-view {
@apply absolute bottom-0 left-0 right-0 top-0 select-none outline outline-1 outline-blue-500;
}
/* 裁剪框辅助线 */
.cropper-dashed-h {
@apply absolute left-0 top-1/3 block h-1/3 w-full border-b border-t border-dashed border-gray-200/50;
}
.cropper-dashed-v {
@apply absolute left-1/3 top-0 block h-full w-1/3 border-l border-r border-dashed border-gray-200/50;
}
/* 裁剪框拖拽区域 */
.cropper-move-area {
@apply absolute left-0 top-0 block h-full w-full cursor-move bg-white/10;
}
/* 边框拖拽线 */
.cropper-line-e,
.cropper-line-n,
.cropper-line-w,
.cropper-line-s {
@apply absolute block bg-blue-500/10;
}
.cropper-line-e {
@apply right-[-3px] top-0 h-full w-1;
}
.cropper-line-n {
@apply left-0 top-[-3px] h-1 w-full;
}
.cropper-line-w {
@apply left-[-3px] top-0 h-full w-1;
}
.cropper-line-s {
@apply bottom-[-3px] left-0 h-1 w-full;
}
/* 拖拽点 */
.cropper-point {
@apply absolute flex h-2 w-2 items-center justify-center bg-blue-500;
}
.cropper-point-inner {
@apply block h-1.5 w-1.5 bg-white;
}
/* 边角拖拽点位置和光标 */
.cropper-point-ne {
@apply right-[-5px] top-[-5px] cursor-ne-resize;
}
.cropper-point-nw {
@apply left-[-5px] top-[-5px] cursor-nw-resize;
}
.cropper-point-sw {
@apply bottom-[-5px] left-[-5px] cursor-sw-resize;
}
.cropper-point-se {
@apply bottom-[-5px] right-[-5px] cursor-se-resize;
}
/* 边中点拖拽点位置和光标 */
.cropper-point-e {
@apply right-[-5px] top-1/2 -mt-1 cursor-e-resize;
}
.cropper-point-n {
@apply left-1/2 top-[-5px] -ml-1 cursor-n-resize;
}
.cropper-point-w {
@apply left-[-5px] top-1/2 -mt-1 cursor-w-resize;
}
.cropper-point-s {
@apply bottom-[-5px] left-1/2 -ml-1 cursor-s-resize;
}
</style>

View File

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

View File

@@ -0,0 +1,55 @@
<script lang="ts" setup>
import type { DocAlertProps } from './types';
import { ref } from 'vue';
import { isDocAlertEnable } from '@vben/hooks';
import { VbenIcon } from '@vben-core/shadcn-ui';
import { openWindow } from '@vben-core/shared/utils';
defineOptions({
name: 'DocAlert',
});
const props = defineProps<DocAlertProps>();
/** 控制组件显示状态 */
const isVisible = ref(true);
function goToUrl() {
openWindow(props.url);
}
function close() {
isVisible.value = false;
}
</script>
<template>
<div
role="alert"
v-if="isDocAlertEnable() && isVisible"
class="border-primary bg-primary/10 relative m-3 my-2 flex h-8 items-center gap-2 rounded-md border p-2"
>
<span class="grid shrink-0 place-items-center">
<VbenIcon icon="mdi:information-outline" class="text-primary size-5" />
</span>
<div class="text-primary min-w-0 flex-1 font-sans text-sm leading-none">
<span class="inline-block">{{ title }}</span>
<a
class="hover:text-success cursor-pointer break-all"
@click="goToUrl"
:title="url"
>
文档地址{{ url }}
</a>
</div>
<span class="grid shrink-0 cursor-pointer place-items-center">
<VbenIcon
icon="mdi:close"
class="text-primary size-5 hover:text-red-500"
@click="close"
/>
</span>
</div>
</template>

View File

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

View File

@@ -0,0 +1,4 @@
export interface DocAlertProps {
title: string;
url: string;
}

View File

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

View File

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

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import type { VNode } from 'vue';
import { computed, ref, useAttrs, watch, watchEffect } from 'vue';
import { usePagination } from '@vben/hooks';
import { EmptyIcon, Grip, listIcons } from '@vben/icons';
import { $t } from '@vben/locales';
import {
Button,
Input,
Pagination,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
VbenIcon,
VbenIconButton,
VbenPopover,
} from '@vben-core/shadcn-ui';
import { isFunction } from '@vben-core/shared/utils';
import { objectOmit, refDebounced, watchDebounced } from '@vueuse/core';
import { fetchIconsData } from './icons';
interface Props {
pageSize?: number;
/** 图标集的名字 */
prefix?: string;
/** 是否自动请求API以获得图标集的数据.提供prefix时有效 */
autoFetchApi?: boolean;
/**
* 图标列表
*/
icons?: string[];
/** Input组件 */
inputComponent?: VNode;
/** 图标插槽名,预览图标将被渲染到此插槽中 */
iconSlot?: string;
/** input组件的值属性名称 */
modelValueProp?: string;
/** 图标样式 */
iconClass?: string;
type?: 'icon' | 'input';
}
const props = withDefaults(defineProps<Props>(), {
prefix: 'ant-design',
pageSize: 36,
icons: () => [],
iconSlot: 'default',
iconClass: 'size-4',
autoFetchApi: true,
modelValueProp: 'modelValue',
inputComponent: undefined,
type: 'input',
});
const emit = defineEmits<{
change: [string];
}>();
const attrs = useAttrs();
const modelValue = defineModel({ default: '', type: String });
const visible = ref(false);
const currentSelect = ref('');
const keyword = ref('');
const keywordDebounce = refDebounced(keyword, 300);
const innerIcons = ref<string[]>([]);
watchDebounced(
() => props.prefix,
async (prefix) => {
if (prefix && prefix !== 'svg' && props.autoFetchApi) {
innerIcons.value = await fetchIconsData(prefix);
}
},
{ immediate: true, debounce: 500, maxWait: 1000 },
);
const currentList = computed(() => {
try {
if (props.prefix) {
if (
props.prefix !== 'svg' &&
props.autoFetchApi &&
props.icons.length === 0
) {
return innerIcons.value;
}
const icons = listIcons('', props.prefix);
if (icons.length === 0) {
console.warn(`No icons found for prefix: ${props.prefix}`);
}
return icons;
} else {
return props.icons;
}
} catch (error) {
console.error('Failed to load icons:', error);
return [];
}
});
const showList = computed(() => {
return currentList.value.filter((item) =>
item.includes(keywordDebounce.value),
);
});
const { paginationList, total, setCurrentPage, currentPage } = usePagination(
showList,
props.pageSize,
);
watchEffect(() => {
currentSelect.value = modelValue.value;
});
watch(
() => currentSelect.value,
(v) => {
emit('change', v);
},
);
const handleClick = (icon: string) => {
currentSelect.value = icon;
modelValue.value = icon;
close();
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
function toggleOpenState() {
visible.value = !visible.value;
}
function open() {
visible.value = true;
}
function close() {
visible.value = false;
}
function onKeywordChange(v: string) {
keyword.value = v;
}
const searchInputProps = computed(() => {
return {
placeholder: $t('ui.iconPicker.search'),
[props.modelValueProp]: keyword.value,
[`onUpdate:${props.modelValueProp}`]: onKeywordChange,
class: 'mx-2',
};
});
function updateCurrentSelect(v: string) {
currentSelect.value = v;
const eventKey = `onUpdate:${props.modelValueProp}`;
if (attrs[eventKey] && isFunction(attrs[eventKey])) {
attrs[eventKey](v);
}
}
const getBindAttrs = computed(() => {
return objectOmit(attrs, [`onUpdate:${props.modelValueProp}`]);
});
defineExpose({ toggleOpenState, open, close });
</script>
<template>
<VbenPopover
v-model:open="visible"
:content-props="{ align: 'end', alignOffset: -11, sideOffset: 8 }"
content-class="p-0 pt-3 w-full"
trigger-class="w-full"
>
<template #trigger>
<template v-if="props.type === 'input'">
<component
v-if="props.inputComponent"
:is="inputComponent"
:[modelValueProp]="currentSelect"
:placeholder="$t('ui.iconPicker.placeholder')"
role="combobox"
:aria-label="$t('ui.iconPicker.placeholder')"
aria-expanded="visible"
:[`onUpdate:${modelValueProp}`]="updateCurrentSelect"
v-bind="getBindAttrs"
>
<template #[iconSlot]>
<VbenIcon
:icon="currentSelect || Grip"
class="size-4"
aria-hidden="true"
/>
</template>
</component>
<div class="relative w-full" v-else>
<Input
v-bind="$attrs"
v-model="currentSelect"
:placeholder="$t('ui.iconPicker.placeholder')"
class="h-8 w-full pr-8"
role="combobox"
:aria-label="$t('ui.iconPicker.placeholder')"
aria-expanded="visible"
/>
<VbenIcon
:icon="currentSelect || Grip"
class="absolute right-1 top-1 size-6"
aria-hidden="true"
/>
</div>
</template>
<VbenIcon
:icon="currentSelect || Grip"
v-else
class="size-4"
v-bind="$attrs"
/>
</template>
<div class="mb-2 flex w-full">
<component
v-if="inputComponent"
:is="inputComponent"
v-bind="searchInputProps"
/>
<Input
v-else
class="mx-2 h-8 w-full"
:placeholder="$t('ui.iconPicker.search')"
v-model="keyword"
/>
</div>
<template v-if="paginationList.length > 0">
<div class="grid max-h-[360px] w-full grid-cols-6 justify-items-center">
<VbenIconButton
v-for="(item, index) in paginationList"
:key="index"
:tooltip="item"
tooltip-side="top"
@click="handleClick(item)"
>
<VbenIcon
:class="{
'text-primary transition-all': currentSelect === item,
}"
:icon="item"
/>
</VbenIconButton>
</div>
<div
v-if="total >= pageSize"
class="flex-center flex justify-end overflow-hidden border-t py-2 pr-3"
>
<Pagination
:items-per-page="36"
:sibling-count="1"
:total="total"
show-edges
size="small"
@update:page="handlePageChange"
>
<PaginationList
v-slot="{ items }"
class="flex w-full items-center gap-1"
>
<PaginationFirst class="size-5" />
<PaginationPrev class="size-5" />
<template v-for="(item, index) in items">
<PaginationListItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
<Button
:variant="item.value === currentPage ? 'default' : 'outline'"
class="size-5 p-0 text-sm"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis
v-else
:key="item.type"
:index="index"
class="size-5"
/>
</template>
<PaginationNext class="size-5" />
<PaginationLast class="size-5" />
</PaginationList>
</Pagination>
</div>
</template>
<template v-else>
<div class="flex-col-center text-muted-foreground min-h-[150px] w-full">
<EmptyIcon class="size-10" />
<div class="mt-1 text-sm">{{ $t('common.noData') }}</div>
</div>
</template>
</VbenPopover>
</template>

View File

@@ -0,0 +1,56 @@
import type { Recordable } from '@vben/types';
/**
* 一个缓存对象,在不刷新页面时,无需重复请求远程接口
*/
export const ICONS_MAP: Recordable<string[]> = {};
interface IconifyResponse {
prefix: string;
total: number;
title: string;
uncategorized?: string[];
categories?: Recordable<string[]>;
aliases?: Recordable<string>;
}
const PENDING_REQUESTS: Recordable<Promise<string[]>> = {};
/**
* 通过Iconify接口获取图标集数据。
* 同一时间多个图标选择器同时请求同一个图标集时,实际上只会发起一次请求(所有请求共享同一份结果)。
* 请求结果会被缓存,刷新页面前同一个图标集不会再次请求
* @param prefix 图标集名称
* @returns 图标集中包含的所有图标名称
*/
export async function fetchIconsData(prefix: string): Promise<string[]> {
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
return ICONS_MAP[prefix];
}
if (Reflect.has(PENDING_REQUESTS, prefix) && PENDING_REQUESTS[prefix]) {
return PENDING_REQUESTS[prefix];
}
PENDING_REQUESTS[prefix] = (async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1000 * 10);
const response: IconifyResponse = await fetch(
`https://api.iconify.design/collection?prefix=${prefix}`,
{ signal: controller.signal },
).then((res) => res.json());
clearTimeout(timeoutId);
const list = response.uncategorized || [];
if (response.categories) {
for (const category in response.categories) {
list.push(...(response.categories[category] || []));
}
}
ICONS_MAP[prefix] = list.map((v) => `${prefix}:${v}`);
} catch (error) {
console.error(`Failed to fetch icons for prefix ${prefix}:`, error);
return [] as string[];
}
return ICONS_MAP[prefix];
})();
return PENDING_REQUESTS[prefix];
}

View File

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

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
interface IFrameProps {
/** iframe 的源地址 */
src: string;
}
defineProps<IFrameProps>();
const loading = ref(true);
const height = ref('');
const frameRef = ref<HTMLElement | null>(null);
function init() {
height.value = `${document.documentElement.clientHeight - 94.5}px`;
loading.value = false;
}
onMounted(() => {
setTimeout(() => {
init();
}, 300);
});
</script>
<template>
<div v-loading="loading" :style="`height:${height}`">
<iframe
ref="frameRef"
:src="src"
class="h-full w-full"
frameborder="no"
scrolling="auto"
></iframe>
</div>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,112 @@
<script lang="ts" setup>
import type { SetupContext } from 'vue';
import type { Recordable } from '@vben/types';
import type {
JsonViewerAction,
JsonViewerProps,
JsonViewerToggle,
JsonViewerValue,
} from './types';
import { computed, useAttrs } from 'vue';
// @ts-ignore
import VueJsonViewer from 'vue-json-viewer';
import { $t } from '@vben/locales';
import { isBoolean } from '@vben-core/shared/utils';
// @ts-ignore
import JsonBigint from 'json-bigint';
defineOptions({ name: 'JsonViewer' });
const props = withDefaults(defineProps<JsonViewerProps>(), {
expandDepth: 1,
copyable: false,
sort: false,
boxed: false,
theme: 'default-json-theme',
expanded: false,
previewMode: false,
showArrayIndex: true,
showDoubleQuotes: false,
});
const emit = defineEmits<{
click: [event: MouseEvent];
copied: [event: JsonViewerAction];
keyClick: [key: string];
toggle: [param: JsonViewerToggle];
valueClick: [value: JsonViewerValue];
}>();
const attrs: SetupContext['attrs'] = useAttrs();
function handleClick(event: MouseEvent) {
if (
event.target instanceof HTMLElement &&
event.target.classList.contains('jv-item')
) {
const pathNode = event.target.closest('.jv-push');
if (!pathNode || !pathNode.hasAttribute('path')) {
return;
}
const param: JsonViewerValue = {
el: event.target,
path: pathNode.getAttribute('path') || '',
depth: Number(pathNode.getAttribute('depth')) || 0,
value: event.target.textContent || undefined,
};
param.value = JSON.parse(param.value);
emit('valueClick', param);
}
emit('click', event);
}
// 支持显示 bigint 数据,如较长的订单号
const jsonData = computed<Record<string, any>>(() => {
if (typeof props.value !== 'string') {
return props.value || {};
}
try {
return JsonBigint({ storeAsString: true }).parse(props.value);
} catch (error) {
console.error('JSON parse error:', error);
return {};
}
});
const bindProps = computed<Recordable<any>>(() => {
const copyable = {
copyText: $t('ui.jsonViewer.copy'),
copiedText: $t('ui.jsonViewer.copied'),
timeout: 2000,
...(isBoolean(props.copyable) ? {} : props.copyable),
};
return {
...props,
...attrs,
value: jsonData.value,
onCopied: (event: JsonViewerAction) => emit('copied', event),
onKeyclick: (key: string) => emit('keyClick', key),
onClick: (event: MouseEvent) => handleClick(event),
copyable: props.copyable ? copyable : false,
};
});
</script>
<template>
<VueJsonViewer v-bind="bindProps">
<template #copy="slotProps">
<slot name="copy" v-bind="slotProps"></slot>
</template>
</VueJsonViewer>
</template>
<style lang="scss">
@use './style.scss';
</style>

View File

@@ -0,0 +1,98 @@
.default-json-theme {
font-family: Consolas, Menlo, Courier, monospace;
font-size: 14px;
color: hsl(var(--foreground));
white-space: nowrap;
background: hsl(var(--background));
&.jv-container.boxed {
border: 1px solid hsl(var(--border));
}
.jv-ellipsis {
display: inline-block;
padding: 0 4px 2px;
font-size: 0.9em;
line-height: 0.9;
vertical-align: 2px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
user-select: none;
background-color: hsl(var(--secondary));
border-radius: 3px;
}
.jv-button {
color: hsl(var(--primary));
}
.jv-key {
color: hsl(var(--heavy-foreground));
}
.jv-item {
&.jv-array {
color: hsl(var(--heavy-foreground));
}
&.jv-boolean {
color: hsl(var(--red-400));
}
&.jv-function {
color: hsl(var(--destructive-foreground));
}
&.jv-number {
color: hsl(var(--info-foreground));
}
&.jv-number-float {
color: hsl(var(--info-foreground));
}
&.jv-number-integer {
color: hsl(var(--info-foreground));
}
&.jv-object {
color: hsl(var(--accent-darker));
}
&.jv-undefined {
color: hsl(var(--secondary-foreground));
}
&.jv-string {
color: hsl(var(--primary));
overflow-wrap: break-word;
white-space: normal;
}
}
&.jv-container .jv-code {
padding: 10px;
&.boxed:not(.open) {
padding-bottom: 20px;
margin-bottom: 10px;
}
&.open {
padding-bottom: 10px;
}
.jv-toggle {
&::before {
padding: 0 2px;
border-radius: 2px;
}
&:hover {
&::before {
background: hsl(var(--accent-foreground));
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
export interface JsonViewerProps {
/** 要展示的结构数据 */
value: any;
/** 展开深度 */
expandDepth?: number;
/** 是否可复制 */
copyable?: boolean;
/** 是否排序 */
sort?: boolean;
/** 显示边框 */
boxed?: boolean;
/** 主题 */
theme?: string;
/** 是否展开 */
expanded?: boolean;
/** 时间格式化函数 */
timeformat?: (time: Date | number | string) => string;
/** 预览模式 */
previewMode?: boolean;
/** 显示数组索引 */
showArrayIndex?: boolean;
/** 显示双引号 */
showDoubleQuotes?: boolean;
}
export interface JsonViewerAction {
action: string;
text: string;
trigger: HTMLElement;
}
export interface JsonViewerValue {
value: any;
path: string;
depth: number;
el: HTMLElement;
}
export interface JsonViewerToggle {
/** 鼠标事件 */
event: MouseEvent;
/** 当前展开状态 */
open: boolean;
}

View File

@@ -0,0 +1,132 @@
import type { App, Directive, DirectiveBinding } from 'vue';
import { h, render } from 'vue';
import { VbenLoading, VbenSpinner } from '@vben-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,
);
}
}

View File

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

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import { VbenLoading } from '@vben-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>

View File

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

View File

@@ -0,0 +1,89 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { Page } from '..';
describe('page.vue', () => {
it('renders title when passed', () => {
const wrapper = mount(Page, {
props: {
title: 'Test Title',
},
});
expect(wrapper.text()).toContain('Test Title');
});
it('renders description when passed', () => {
const wrapper = mount(Page, {
props: {
description: 'Test Description',
},
});
expect(wrapper.text()).toContain('Test Description');
});
it('renders default slot content', () => {
const wrapper = mount(Page, {
slots: {
default: '<p>Default Slot Content</p>',
},
});
expect(wrapper.html()).toContain('<p>Default Slot Content</p>');
});
it('renders footer slot when showFooter is true', () => {
const wrapper = mount(Page, {
props: {
showFooter: true,
},
slots: {
footer: '<p>Footer Slot Content</p>',
},
});
expect(wrapper.html()).toContain('<p>Footer Slot Content</p>');
});
it('applies the custom contentClass', () => {
const wrapper = mount(Page, {
props: {
contentClass: 'custom-class',
},
});
const contentDiv = wrapper.find('.p-4');
expect(contentDiv.classes()).toContain('custom-class');
});
it('does not render title slot if title prop is provided', () => {
const wrapper = mount(Page, {
props: {
title: 'Test Title',
},
slots: {
title: '<p>Title Slot Content</p>',
},
});
expect(wrapper.text()).toContain('Title Slot Content');
expect(wrapper.html()).not.toContain('Test Title');
});
it('does not render description slot if description prop is provided', () => {
const wrapper = mount(Page, {
props: {
description: 'Test Description',
},
slots: {
description: '<p>Description Slot Content</p>',
},
});
expect(wrapper.text()).toContain('Description Slot Content');
expect(wrapper.html()).not.toContain('Test Description');
});
});

View File

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

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { StyleValue } from 'vue';
import type { PageProps } from './types';
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
import { CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT } from '@vben-core/shared/constants';
import { cn } from '@vben-core/shared/utils';
defineOptions({
name: 'Page',
});
const { autoContentHeight = false, heightOffset = 0 } =
defineProps<PageProps>();
const headerHeight = ref(0);
const footerHeight = ref(0);
const docHeight = ref(0);
const shouldAutoHeight = ref(false);
const headerRef = useTemplateRef<HTMLDivElement>('headerRef');
const footerRef = useTemplateRef<HTMLDivElement>('footerRef');
const docRef = useTemplateRef<HTMLDivElement>('docRef');
const contentStyle = computed<StyleValue>(() => {
if (autoContentHeight) {
return {
height: `calc(var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT}) - ${headerHeight.value}px - ${footerHeight.value}px - ${docHeight.value}px - ${typeof heightOffset === 'number' ? `${heightOffset}px` : heightOffset})`,
overflowY: shouldAutoHeight.value ? 'auto' : 'unset',
};
}
return {};
});
async function calcContentHeight() {
if (!autoContentHeight) {
return;
}
await nextTick();
headerHeight.value = headerRef.value?.offsetHeight || 0;
footerHeight.value = footerRef.value?.offsetHeight || 0;
docHeight.value = docRef.value?.offsetHeight || 0;
setTimeout(() => {
shouldAutoHeight.value = true;
}, 30);
}
function isDocAlertEnable(): boolean {
return import.meta.env.VITE_APP_DOCALERT_ENABLE !== 'false';
}
onMounted(() => {
calcContentHeight();
});
</script>
<template>
<div class="relative flex min-h-full flex-col">
<div
v-if="$slots.doc && isDocAlertEnable()"
ref="docRef"
:class="
cn(
'bg-card border-border relative mx-4 flex items-start rounded-md border-b',
)
"
>
<div class="flex-auto">
<slot name="doc"></slot>
</div>
</div>
<div
v-if="
description ||
$slots.description ||
title ||
$slots.title ||
$slots.extra
"
ref="headerRef"
:class="
cn(
'bg-card border-border relative flex items-end border-b px-6 py-4',
headerClass,
)
"
>
<div class="flex-auto">
<slot name="title">
<div v-if="title" class="mb-2 flex text-lg font-semibold">
{{ title }}
</div>
</slot>
<slot name="description">
<p v-if="description" class="text-muted-foreground">
{{ description }}
</p>
</slot>
</div>
<div v-if="$slots.extra">
<slot name="extra"></slot>
</div>
</div>
<div :class="cn('h-full p-4', contentClass)" :style="contentStyle">
<slot></slot>
</div>
<div
v-if="$slots.footer"
ref="footerRef"
:class="cn('bg-card align-center flex px-6 py-4', footerClass)"
>
<slot name="footer"></slot>
</div>
</div>
</template>

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
import type { ComputedRef, Directive } from 'vue';
import { useTippy } from 'vue-tippy';
export default function useTippyDirective(isDark: ComputedRef<boolean>) {
const directive: Directive = {
mounted(el, binding, vnode) {
const opts =
typeof binding.value === 'string'
? { content: binding.value }
: binding.value || {};
const modifiers = Object.keys(binding.modifiers || {});
const placement = modifiers.find((modifier) => modifier !== 'arrow');
const withArrow = modifiers.includes('arrow');
if (placement) {
opts.placement = opts.placement || placement;
}
if (withArrow) {
opts.arrow = opts.arrow === undefined ? true : opts.arrow;
}
if (vnode.props && vnode.props.onTippyShow) {
opts.onShow = function (...args: any[]) {
return vnode.props?.onTippyShow(...args);
};
}
if (vnode.props && vnode.props.onTippyShown) {
opts.onShown = function (...args: any[]) {
return vnode.props?.onTippyShown(...args);
};
}
if (vnode.props && vnode.props.onTippyHidden) {
opts.onHidden = function (...args: any[]) {
return vnode.props?.onTippyHidden(...args);
};
}
if (vnode.props && vnode.props.onTippyHide) {
opts.onHide = function (...args: any[]) {
return vnode.props?.onTippyHide(...args);
};
}
if (vnode.props && vnode.props.onTippyMount) {
opts.onMount = function (...args: any[]) {
return vnode.props?.onTippyMount(...args);
};
}
if (el.getAttribute('title') && !opts.content) {
opts.content = el.getAttribute('title');
el.removeAttribute('title');
}
if (el.getAttribute('content') && !opts.content) {
opts.content = el.getAttribute('content');
}
useTippy(el, opts);
},
unmounted(el) {
if (el.$tippy) {
el.$tippy.destroy();
} else if (el._tippy) {
el._tippy.destroy();
}
},
updated(el, binding) {
const opts =
typeof binding.value === 'string'
? { content: binding.value, theme: isDark.value ? '' : 'light' }
: Object.assign(
{ theme: isDark.value ? '' : 'light' },
binding.value,
);
if (el.getAttribute('title') && !opts.content) {
opts.content = el.getAttribute('title');
el.removeAttribute('title');
}
if (el.getAttribute('content') && !opts.content) {
opts.content = el.getAttribute('content');
}
if (el.$tippy) {
el.$tippy.setProps(opts || {});
} else if (el._tippy) {
el._tippy.setProps(opts || {});
}
},
};
return directive;
}

View File

@@ -0,0 +1,67 @@
import type { DefaultProps, Props } from 'tippy.js';
import type { App, SetupContext } from 'vue';
import { h, watchEffect } from 'vue';
import { setDefaultProps, Tippy as TippyComponent } from 'vue-tippy';
import { usePreferences } from '@vben-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,
);
};

View File

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

View File

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