Procházet zdrojové kódy

refactor(架构调整): 从v4-component 基本移植基本的component过来,待整理typescript架构与类处理

gemercheung před 2 roky
rodič
revize
719e2a26a9

+ 156 - 0
packages/components/basic/cropper/cropper.vue

@@ -0,0 +1,156 @@
+<template>
+    <div>
+        <Confirm :title="title" :func="clickHandler" :noText="noText" :okText="okText">
+            <template #content>
+                <div>
+                    <div class="cropper-layer" :style="style">
+                        <VueCropper v-if="show" ref="vmRef" v-bind="option" v-on="on" />
+                    </div>
+                    <div class="size" v-if="showSize">
+                        <ui-input :label="`Logo-${longSize}`" type="radio" name="size" :modelValue="sizeType == 1" @update:modelValue="changSize(1)" />
+                        <ui-input :label="`Logo-${squareSize}`" type="radio" name="size" :modelValue="sizeType == 2" @update:modelValue="changSize(2)" />
+                    </div>
+                </div>
+            </template>
+        </Confirm>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { VueCropper } from 'vue-cropper'
+import Confirm from '../dialog/confirm.vue'
+import { computed, defineProps, ref, nextTick } from 'vue'
+import 'vue-cropper/dist/index.css'
+// import { useI18n } from '@/i18n'
+// const { t } = useI18n({ useScope: 'global' })
+const sizeType = ref(1)
+const layerWidth = 500
+const props = defineProps({
+    fixedNumber: {
+        type: Array,
+        default: () => [1, 1],
+    },
+    img: { type: String },
+    cb: {
+        type: Function,
+    },
+    showSize: {
+        type: Boolean,
+        default: false,
+    },
+    noText: {
+        type: String,
+        default: '取消',
+    },
+    okText: {
+        type: String,
+        default: '确认',
+    },
+    title: {
+        type: String,
+        default: '裁剪',
+    },
+    longSize: {
+        type: String,
+        default: '长型',
+    },
+    squareSize: {
+        type: String,
+        default: '方型',
+    },
+})
+const show = ref(true)
+// const fixedNumber = props.fixedNumber
+const getHeight = width => (fixedNumber[1] / fixedNumber[0]) * width
+
+const option = {
+    outputSize: 1,
+    outputType: 'png',
+    info: false,
+    full: true,
+    fixed: true,
+    canScale: true,
+    fixedNumber: props.fixedNumber,
+    canMove: true,
+    canMoveBox: true,
+    fixedBox: false,
+    original: false,
+    autoCrop: true,
+    autoCropWidth: layerWidth / 2,
+    autoCropHeight: getHeight(layerWidth / 2),
+    centerBox: false,
+    mode: 'contain',
+    maxImgSize: 400,
+    // ...props,
+}
+const changSize = type => {
+    if (sizeType.value != type) {
+        sizeType.value = type
+        show.value = false
+        if (type == 1) {
+            option.fixedNumber = [2, 1]
+        } else {
+            option.fixedNumber = [1, 1]
+        }
+
+        nextTick(() => {
+            show.value = true
+        })
+    }
+}
+const style = computed(() => ({
+    width: layerWidth + 'px',
+    height: getHeight(layerWidth) + 'px',
+}))
+
+const vmRef = ref()
+const on = {
+    imgLoad(status) {
+        if (status !== 'success') {
+            props.cb('图片加载失败')
+        }
+    },
+}
+
+const clickHandler = async status => {
+    if (status === 'ok') {
+        let data = await Promise.all([new Promise(resolve => vmRef.value.getCropBlob(resolve)), new Promise(resolve => vmRef.value.getCropData(resolve))])
+        if (props.showSize) {
+            data.push(sizeType.value)
+        }
+        props.cb(null, data)
+    } else {
+        props.cb()
+    }
+}
+</script>
+
+<script>
+export default { name: 'UiCropper' }
+</script>
+
+<style lang="scss">
+.vue-cropper {
+    background-repeat: repeat;
+}
+
+.cropper-view-box {
+    outline-color: var(--color-main-normal) !important;
+}
+.crop-point {
+    background-color: var(--color-main-normal) !important;
+}
+.size {
+    width: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-top: 20px;
+}
+.ui-input {
+    margin-right: 30px;
+    &:last-of-type {
+        margin-right: 0;
+    }
+}
+</style>

+ 49 - 0
packages/components/basic/cropper/index.js

@@ -0,0 +1,49 @@
+import Cropper from './cropper.vue'
+import { mount } from '../../utils/componentHelper'
+import { toRawType } from '../../utils/index'
+
+Cropper.use = function use(app) {
+    const isCropper = false
+    Cropper.open = function (config) {
+        if (isCropper) {
+            let tips
+            if (config.cliping) {
+                tips = config.cliping
+            } else {
+                tips = '正在裁剪'
+            }
+            return Promise.reject(tips)
+        }
+        if (toRawType(config) === 'String') {
+            config = { img: config }
+        }
+        if (!config || !config.img) {
+            let tips
+            if (config.clipEmpty) {
+                tips = config.clipEmpty
+            } else {
+                tips = '请传入裁剪图片'
+            }
+            return Promise.reject(tips)
+        }
+
+        return new Promise((resolve, reject) => {
+            const { destroy } = mount(Cropper, {
+                app,
+                props: {
+                    ...config,
+                    cb(err, data) {
+                        destroy()
+                        if (err) {
+                            reject(err)
+                        } else {
+                            resolve(data)
+                        }
+                    },
+                },
+            })
+        })
+    }
+}
+
+export default Cropper

+ 1 - 0
packages/components/basic/gate/constant.js

@@ -0,0 +1 @@
+export const Relation = Symbol('relation')

+ 28 - 0
packages/components/basic/gate/content.vue

@@ -0,0 +1,28 @@
+<template>
+    <div class="ui-gate-content" :class="{ active }" v-if="brotherInstances">
+        <slot :active="active"></slot>
+    </div>
+</template>
+
+<script setup>
+// getCurrentInstance
+import { onBeforeMount, ref, inject, onUnmounted } from 'vue'
+import { Relation } from './constant'
+
+const active = ref(false)
+const brotherInstances = inject(Relation).value
+
+if (brotherInstances) {
+    onBeforeMount(() => brotherInstances.push(active))
+    onUnmounted(() => {
+        const index = brotherInstances.indexOf(active)
+        if (~index) {
+            brotherInstances.splice(index, 1)
+        }
+    })
+}
+</script>
+
+<script>
+export default { name: 'UiGateContent' }
+</script>

+ 4 - 0
packages/components/basic/gate/index.js

@@ -0,0 +1,4 @@
+import Gate from './layer.vue'
+import GateContent from './content.vue'
+
+export { Gate, GateContent }

+ 47 - 0
packages/components/basic/gate/layer.vue

@@ -0,0 +1,47 @@
+<template>
+    <div
+        class="ui-gate-layer"
+        :style="{
+            height: normalizeUnitToStyle(height),
+            '--len': contentInstances.length,
+            '--current': slideIndex,
+        }"
+    >
+        <div class="ui-gate-slides">
+            <slot></slot>
+        </div>
+    </div>
+</template>
+
+<script setup>
+// watchEffect
+import { ref, defineProps, computed, provide, watch } from 'vue'
+import { normalizeUnitToStyle } from '../../utils'
+import { Relation } from './constant'
+
+const contentInstances = ref([])
+const props = defineProps({
+    index: {
+        type: [Number, String],
+        default: 0,
+    },
+    height: {
+        type: [Number, String],
+    },
+})
+
+const slideIndex = computed(() => (props.index > contentInstances.value.length - 1 ? contentInstances.value.length - 1 : props.index < 0 ? 0 : props.index))
+
+watch([contentInstances, slideIndex], () => {
+    for (let i = 0; i < contentInstances.value.length; i++) {
+        const instance = contentInstances.value[i]
+        instance.value = i === slideIndex.value
+    }
+})
+
+provide(Relation, contentInstances)
+</script>
+
+<script>
+export default { name: 'UiGate' }
+</script>

+ 1 - 0
packages/components/basic/group/constant.js

@@ -0,0 +1 @@
+export const Relation = Symbol('group-children')

+ 4 - 0
packages/components/basic/group/index.js

@@ -0,0 +1,4 @@
+import Group from './ui-group.vue'
+import GroupOption from './ui-group-option.vue'
+
+export { Group, GroupOption }

+ 34 - 0
packages/components/basic/group/ui-group-option.vue

@@ -0,0 +1,34 @@
+<template>
+    <div class="group-option">
+        <span class="group-option-label" v-if="props.label">
+            {{ props.label }}
+        </span>
+        <slot></slot>
+    </div>
+</template>
+
+<script setup>
+import { Relation } from './constant'
+import { inject, onBeforeMount, onUnmounted, getCurrentInstance } from 'vue'
+const props = defineProps({
+    label: String,
+})
+
+const brotherInstances = inject(Relation)
+const instance = getCurrentInstance()
+
+if (brotherInstances.value) {
+    onBeforeMount(() => (brotherInstances.value = [...brotherInstances.value, instance]))
+    onUnmounted(() => {
+        const index = brotherInstances.value.indexOf(instance)
+        if (~index) {
+            brotherInstances.value.splice(index, 1)
+            brotherInstances.value = [...brotherInstances.value]
+        }
+    })
+}
+</script>
+
+<script>
+export default { name: 'UiGroupOption' }
+</script>

+ 69 - 0
packages/components/basic/group/ui-group.vue

@@ -0,0 +1,69 @@
+<template>
+    <div class="ui-group" :class="{ control }">
+        <template v-if="!$slots.header">
+            <h3 v-if="props.title" class="group-title" :class="!$slots.default && contentStyle">
+                {{ props.title }}
+                <span class="group-icon" :class="animationRef && { show: animationRef.show }" @click="control && animationRef.changeShow()" v-if="$slots.icon || control">
+                    <slot name="icon" v-if="$slots.icon"></slot>
+                    <icon type="pull-down" size="12px" v-else />
+                </span>
+            </h3>
+        </template>
+        <div v-else class="group-title" :class="!$slots.default && contentStyle">
+            <slot name="header"></slot>
+
+            <span class="group-icon" :class="animationRef && { show: animationRef.show }" @click="control && animationRef.changeShow()" v-if="$slots.icon || control">
+                <slot name="icon" v-if="$slots.icon"></slot>
+                <icon type="pull-down" size="12px" v-else />
+            </span>
+        </div>
+
+        <template v-if="$slots.default">
+            <UISizeAnimation ref="animationRef" class="group-content" :class="contentStyle" v-if="control">
+                <slot></slot>
+            </UISizeAnimation>
+            <div class="group-content" :class="contentStyle" v-else>
+                <slot></slot>
+            </div>
+        </template>
+    </div>
+</template>
+
+<script setup>
+import icon from '../icon'
+import UISizeAnimation from '../size-animation'
+import { watchEffect, watch, ref, computed, provide } from 'vue'
+import { Relation } from './constant'
+
+const animationRef = ref(null)
+const props = defineProps({
+    title: String,
+    border: Boolean,
+    borderTop: Boolean,
+    borderBottom: Boolean,
+    control: Boolean,
+    show: Boolean,
+})
+
+const contentStyle = computed(() => ({
+    'border-bottom': props.borderBottom || props.border,
+    'border-top': props.borderTop || props.border,
+}))
+
+const contentInstances = ref([])
+provide(Relation, contentInstances)
+
+watchEffect(() => {
+    if (animationRef.value) {
+        animationRef.value.changeShow(props.show)
+    }
+})
+
+watch(contentInstances, () => {
+    animationRef.value && animationRef.value.refer()
+})
+</script>
+
+<script>
+export default { name: 'UiGroup' }
+</script>

+ 75 - 0
packages/components/basic/guide/index.vue

@@ -0,0 +1,75 @@
+<template>
+    <div class="guide" v-if="mount && (msg || $slots.default)" ref="guideRef" :class="{ 'floating-mode': floatClass }">
+        <slot name="content" :show="shouldShow"></slot>
+        <UIFloating v-if="floatClass" :mount="mountEl" :refer="guideRef" dire="bottom" :class="`guide-floating ${props.floatClass}  ${type}`">
+            <Bubble :show="shouldShow" class="guide-bubble" :type="type">
+                <template v-if="msg">
+                    <p class="default-msg">{{ msg }}</p>
+                </template>
+                <slot v-else></slot>
+
+                <span class="guide-close" @click="shouldShow = false">
+                    <ui-icon type="close" ctrl />
+                </span>
+            </Bubble>
+        </UIFloating>
+
+        <Bubble :show="shouldShow" class="guide-bubble" :type="type" v-else>
+            <template v-if="msg">
+                <p class="default-msg">{{ msg }}</p>
+            </template>
+            <slot v-else></slot>
+
+            <span class="guide-close" @click="shouldShow = false">
+                <ui-icon type="close" ctrl />
+            </span>
+        </Bubble>
+    </div>
+    <slot name="content" v-else :show="false"></slot>
+</template>
+
+<script setup>
+import Bubble from '../bubble'
+import UIFloating from '../floating/index.vue'
+import { ref, watch } from 'vue'
+
+const props = defineProps({
+    mark: {
+        type: String,
+    },
+    msg: {
+        type: String,
+    },
+    floatClass: {
+        type: String,
+    },
+    type: {
+        type: String,
+        default: 'top',
+    },
+})
+
+const mountEl = document.body
+const guideRef = ref()
+const shouldShow = ref(true)
+if (props.mark) {
+    shouldShow.value = !localStorage.getItem(props.mark)
+    if (shouldShow.value) {
+        watch(shouldShow, (newv, oldv) => {
+            if (!newv && oldv) {
+                setTimeout(() => {
+                    localStorage.setItem(props.mark, 1)
+                })
+            }
+        })
+    }
+} else {
+    shouldShow.value = true
+}
+
+const mount = ref(shouldShow.value)
+</script>
+
+<script>
+export default { name: 'UiGuide' }
+</script>

+ 35 - 0
packages/components/basic/menu-item/index.vue

@@ -0,0 +1,35 @@
+<template>
+    <div class="ui-menu-item" :class="{ active }" ref="self" @mouseenter="emit('enter')" @mouseleave="emit('leave')" @click="emit('click')">
+        <UIIcon :type="icon" size="18px" />
+        <span>{{ text }}</span>
+        <slot></slot>
+    </div>
+</template>
+
+<script setup>
+import UIIcon from '../icon'
+import { defineProps, defineExpose, defineEmits, ref } from 'vue'
+
+const self = ref(null)
+// const props =
+defineProps({
+    icon: {
+        type: String,
+        require: true,
+    },
+    text: {
+        type: String,
+        require: true,
+    },
+    active: {
+        type: Boolean,
+    },
+})
+const emit = defineEmits(['leave', 'enter', 'click'])
+
+defineExpose({ dom: self })
+</script>
+
+<script>
+export default { name: 'UiMenuItem' }
+</script>

+ 49 - 0
packages/components/basic/message/index.js

@@ -0,0 +1,49 @@
+import Message from './message.vue'
+import { mount } from '../../utils/componentHelper'
+import { toRawType } from '../../utils/index'
+import { computed, ref } from 'vue'
+
+const types = ['success', 'warning', 'error']
+
+Message.use = function use(app) {
+    const indexs = ref([])
+    Message.show = function (config) {
+        if (toRawType(config) === 'String') {
+            config = { msg: config }
+        }
+
+        config.time = config.time || 3000
+        config.type = types.includes(config.type) ? config.type : types[0]
+
+        const instance = ref(null)
+        const index = computed(() => (instance.value ? indexs.value.indexOf(instance) : 0))
+        const hide = () => {
+            instance.value.destroy()
+            indexs.value = indexs.value.filter(i => i !== instance.value)
+        }
+
+        instance.value = mount(Message, {
+            app,
+            props: {
+                ...config,
+                index,
+                destroy: hide,
+            },
+        })
+        indexs.value.push(instance)
+    }
+
+    for (const type of types) {
+        Message[type] = config => {
+            if (toRawType(config) === 'String') {
+                config = {
+                    msg: config,
+                    type,
+                }
+            }
+            return Message.show(config)
+        }
+    }
+}
+
+export default Message

+ 52 - 0
packages/components/basic/message/message.vue

@@ -0,0 +1,52 @@
+<template>
+    <teleport to="body">
+        <transition name="fade">
+            <div class="ui-message" :style="{ zIndex: zIndex, marginTop: `${index.value * 60}px` }" :class="type" v-if="show">
+                <ui-icon :type="icons[type]" class="icon" />
+                <p>{{ msg }}</p>
+            </div>
+        </transition>
+    </teleport>
+</template>
+
+<script setup>
+import uiIcon from '../icon'
+import getZindex from '../../utils/zindex'
+import { defineProps, onMounted, ref, nextTick } from 'vue'
+
+const props = defineProps({
+    msg: {
+        type: String,
+    },
+    type: {
+        type: String,
+    },
+    time: {
+        type: Number,
+    },
+    destroy: {
+        type: Function,
+    },
+    index: {},
+})
+const zIndex = getZindex()
+const icons = {
+    success: 'state_s',
+    warning: 'state_e',
+    error: 'state_f',
+}
+const show = ref(false)
+
+if (props.time) {
+    setTimeout(() => {
+        show.value = false
+        setTimeout(props.destroy, 500)
+    }, props.time)
+}
+
+onMounted(() => nextTick(() => (show.value = true)))
+</script>
+
+<script>
+export default { name: 'UiMessage' }
+</script>

+ 101 - 0
packages/components/basic/scrollbar/index.css

@@ -0,0 +1,101 @@
+.x-scrollbar {
+    position: relative;
+}
+
+.x-scrollbar__container {
+    position: relative;
+    z-index: 1;
+    width: 100%;
+    height: 100%;
+    overflow: auto;
+}
+
+.x-scrollbar__container--hideScrollbar {
+    scrollbar-width: none !important;
+    -ms-overflow-style: none !important;
+}
+
+.x-scrollbar__container--hideScrollbar::-webkit-scrollbar {
+    display: none !important;
+}
+
+.x-scrollbar__container--preventDefault {
+    overscroll-behavior: contain;
+    -ms-scroll-chaining: none;
+}
+
+.x-scrollbar__content {
+    display: inline-block;
+    vertical-align: middle;
+    min-width: 100%;
+}
+
+/* 轨道 */
+.x-scrollbar__track-x,
+.x-scrollbar__track-y {
+  overflow: hidden;
+    opacity: 1;
+    position: absolute;
+    z-index: 2;
+    transition: background-color 0.2s linear, opacity 0.2s linear;
+}
+
+.x-scrollbar__track-x {
+    left: 0;
+    bottom: 0;
+    width: 100%;
+    height: 8px;
+}
+
+.x-scrollbar__track-y {
+    top: 0;
+    right: 0;
+    height: 100%;
+    width: 8px;
+}
+
+/* 滑块 */
+.x-scrollbar__thumb-x,
+.x-scrollbar__thumb-y {
+    position: absolute;
+    background-color: rgba(255, 255, 255, 0.2);
+    border-radius: 5px;
+    transition: height 0.2s ease-in-out, width 0.2s ease-in-out;
+}
+
+.x-scrollbar__thumb-x {
+    height: 5px;
+    bottom: 0px;
+}
+
+.x-scrollbar__thumb-y {
+    width: 5px;
+    right: 0px;
+}
+
+/* 激活后大小 */
+.x-scrollbar__track-x:hover .x-scrollbar__thumb-x,
+.x-scrollbar__track--draging .x-scrollbar__thumb-x {
+    height: 8px;
+}
+
+.x-scrollbar__track-y:hover .x-scrollbar__thumb-y,
+.x-scrollbar__track--draging .x-scrollbar__thumb-y {
+    width: 8px;
+}
+
+/* 鼠标移入容器 => 显示滑块 */
+.x-scrollbar-keep > .x-scrollbar__track-x,
+.x-scrollbar-keep > .x-scrollbar__track-y,
+.x-scrollbar:hover > .x-scrollbar__track-x,
+.x-scrollbar:hover > .x-scrollbar__track-y {
+    opacity: 1;
+}
+
+/* 鼠标移入轨道 || 拖动过程中 => 显示轨道 & 高亮滑块 */
+.x-scrollbar__track-x:hover,
+.x-scrollbar__track-y:hover,
+.x-scrollbar__track-x.x-scrollbar__track--draging,
+.x-scrollbar__track-y.x-scrollbar__track--draging {
+    opacity: 1 !important;
+}

+ 357 - 0
packages/components/basic/scrollbar/index.js

@@ -0,0 +1,357 @@
+/*!
+ * x-scrollbar 自定义滚动条插件
+ * 版本: v3.1.0
+ * 作者: 清晨的阳光(QQ:765550360)
+ * 许可: MIT
+ * https://gitee.com/xujz520/x-scrollbar
+ */
+
+import './index.css'
+
+class XScrollbar {
+    constructor(dom, options) {
+        this.$dom = dom
+        if (this.$dom.classList.contains('x-scrollbar')) return
+        this.$dom.classList.add('x-scrollbar')
+
+        // 移动端检测
+        this.isMobile = window.navigator.userAgent.toLowerCase().indexOf('mobile') != -1
+
+        // 合并配置
+        let defaultOptions = {
+            // 响应容器和内容大小改变(自动更新滚动条)
+            autoUpdate: true,
+            // 阻止向上传递滚动事件
+            preventDefault: true,
+            // 仅水平滚动(拨动鼠标滚轮时将作用于X轴)
+            onlyHorizontal: false,
+            // 自动隐藏
+            autoHide: true,
+        }
+        let defaultStyle = {
+            // 滑块大小
+            thumbSize: '5px',
+            // 轨道颜色
+            trackBackground: '#ddd',
+            // 滑块颜色
+            thumbBackground: '#5f5f5f',
+            // 滑块圆角大小
+            thumbRadius: '5px',
+        }
+        Object.assign(this, defaultOptions, defaultStyle, options)
+
+        // 构造dom
+        let scrollLeft = this.$dom.scrollLeft
+        let scrollTop = this.$dom.scrollTop
+        this.$container = this.html2dom('<div class="x-scrollbar__container"></div>')
+        this.$content = this.html2dom('<div class="x-scrollbar__content"></div>')
+        this.$trackX = this.html2dom('<div class="x-scrollbar__track-x"></div>')
+        this.$trackY = this.html2dom('<div class="x-scrollbar__track-y"></div>')
+        this.$thumbX = this.html2dom('<div class="x-scrollbar__thumb-x"></div>')
+        this.$thumbY = this.html2dom('<div class="x-scrollbar__thumb-y"></div>')
+        this.$trackX.appendChild(this.$thumbX)
+        this.$trackY.appendChild(this.$thumbY)
+        let childNodes = []
+        Array.prototype.forEach.call(this.$dom.childNodes, function (node) {
+            childNodes.push(node)
+        })
+        childNodes.forEach(
+            function (node) {
+                this.$content.appendChild(node)
+            }.bind(this)
+        )
+        this.$container.appendChild(this.$content)
+        this.$dom.appendChild(this.$container)
+
+        // 处理内边距
+        let styleObj = getComputedStyle(this.$dom)
+        let padding = `${styleObj.paddingTop} ${styleObj.paddingRight} ${styleObj.paddingBottom} ${styleObj.paddingLeft}`
+        if (padding != '0px 0px 0px 0px') {
+            this.$dom.style.padding = '0px 0px 0px 0px'
+            this.$container.style.padding = padding
+        }
+
+        // 设置初始值
+        this.$container.scrollLeft = scrollLeft
+        this.$container.scrollTop = scrollTop
+
+        if (this.preventDefault) {
+            this.$container.classList.add('x-scrollbar__container--preventDefault')
+        }
+
+        if (this.isMobile) return
+
+        this.$dom.appendChild(this.$trackX)
+        this.$dom.appendChild(this.$trackY)
+        this.$container.classList.add('x-scrollbar__container--hideScrollbar')
+        if (JSON.stringify(defaultStyle) != JSON.stringify(Object.keys(defaultStyle).reduce((obj, k) => ({ ...obj, [k]: this[k] }), {}))) {
+            this.style()
+        }
+
+        // 自动隐藏
+        if (!this.autoHide) this.$dom.classList.add('x-scrollbar-keep')
+
+        // 绑定事件
+        this.bindScroll()
+        this.bindDrag()
+        if (this.onlyHorizontal) {
+            this.bindWheel()
+        }
+
+        // 响应容器和内容大小改变
+        if (this.autoUpdate) {
+            // 首次自动触发
+            this.resizeObserver()
+        } else {
+            this.update()
+        }
+    }
+
+    /**
+     * 设置滑块大小
+     */
+    setThumbSize() {
+        // (clientWidth / scrollWidth) = (滑块大小 / clientWidth)
+        // 最大滑动距离 = clientWidth - 滑块大小
+        // 最大滚动距离 = scrollWidth - clientWidth
+        // (滑动距离 / 最大滑动距离) = (滚动距离 / 最大滚动距离)
+
+        // 容器大小
+        this.clientWidth = this.$container.clientWidth
+        this.clientHeight = this.$container.clientHeight
+        // 内容大小
+        this.scrollWidth = this.$container.scrollWidth
+        this.scrollHeight = this.$container.scrollHeight
+        //是否存在滚动条
+        this.hasXScrollbar = this.scrollWidth > this.clientWidth
+        this.hasYScrollbar = this.scrollHeight > this.clientHeight
+        //滑块大小
+        this.thumbXWidth = Math.max((this.clientWidth / this.scrollWidth) * this.clientWidth, 30)
+        this.thumbYHeight = Math.max((this.clientHeight / this.scrollHeight) * this.clientHeight, 30)
+        //最大滑动距离
+        this.thumbXMaxLeft = this.clientWidth - this.thumbXWidth
+        this.thumbYMaxTop = this.clientHeight - this.thumbYHeight
+        //最大滚动距离
+        this.maxScrollLeft = this.scrollWidth - this.clientWidth
+        this.maxScrollTop = this.scrollHeight - this.clientHeight
+
+        this.$trackX.style.display = this.hasXScrollbar ? 'block' : 'none'
+        this.$trackY.style.display = this.hasYScrollbar ? 'block' : 'none'
+        this.$thumbX.style.width = this.thumbXWidth + 'px'
+        this.$thumbY.style.height = this.thumbYHeight + 'px'
+    }
+
+    /**
+     * 拖动事件
+     */
+    bindDrag() {
+        // 上一次的拖动位置
+        let screenX = null
+        let screenY = null
+
+        this.$thumbX.addEventListener('mousedown', e => {
+            this.$trackX.classList.add('x-scrollbar__track--draging')
+            this.thumbXActive = true
+            screenX = e.screenX
+        })
+
+        this.$thumbY.addEventListener('mousedown', e => {
+            this.$trackY.classList.add('x-scrollbar__track--draging')
+            this.thumbYActive = true
+            screenY = e.screenY
+        })
+
+        document.addEventListener('mouseup', _ => {
+            this.$trackX.classList.remove('x-scrollbar__track--draging')
+            this.$trackY.classList.remove('x-scrollbar__track--draging')
+            this.thumbXActive = false
+            this.thumbYActive = false
+        })
+
+        document.addEventListener('mousemove', e => {
+            if (!(this.thumbXActive || this.thumbYActive)) return
+            e.preventDefault()
+
+            requestAnimationFrame(() => {
+                if (this.thumbXActive) {
+                    let offset = e.screenX - screenX
+                    screenX = e.screenX
+                    let left = Math.max(Math.min(parseFloat(this.$thumbX.style.left || 0) + offset, this.thumbXMaxLeft), 0)
+                    this.$thumbX.style.left = left + 'px'
+                    this.$container.scrollLeft = (left / this.thumbXMaxLeft) * this.maxScrollLeft
+                } else {
+                    let offset = e.screenY - screenY
+                    screenY = e.screenY
+                    let top = Math.max(Math.min(parseFloat(this.$thumbY.style.top || 0) + offset, this.thumbYMaxTop), 0)
+                    this.$thumbY.style.top = top + 'px'
+                    this.$container.scrollTop = (top / this.thumbYMaxTop) * this.maxScrollTop
+                }
+            })
+        })
+    }
+
+    /**
+     * 仅水平滚动(拨动鼠标滚轮时将作用于X轴)
+     */
+    bindWheel() {
+        let easeout = (start, end) => {
+            if (Math.abs(end - start) <= 1) return end
+            return start + (end - start) / 4
+        }
+
+        this.$container.addEventListener('wheel', e => {
+            // 仅响应 y 滚动 => 作用于 x
+            if (!this.hasXScrollbar) return
+            if (e.deltaY && !e.shiftKey) {
+                // 结束值
+                this.scrollLeft = Math.max(Math.min((this.scrollLeft || this.$container.scrollLeft) + (e.deltaY > 0 ? 100 : -100), this.maxScrollLeft), 0)
+                this.left = (this.scrollLeft / this.maxScrollLeft) * this.thumbXMaxLeft
+
+                // 阻止向上传递 || !(终点)
+                if (this.preventDefault || !(this.scrollLeft == 0 || this.scrollLeft == this.maxScrollLeft)) {
+                    e.preventDefault()
+                    e.stopPropagation()
+                }
+
+                if (this.reqId) return
+
+                // 起始值
+                let scrollLeft = this.$container.scrollLeft
+                let left = parseFloat(this.$thumbX.style.left || 0)
+
+                let animate = () => {
+                    scrollLeft = easeout(scrollLeft, this.scrollLeft)
+                    left = easeout(left, this.left)
+                    this.$container.scrollLeft = scrollLeft
+                    this.$thumbX.style.left = left + 'px'
+                    this.innerScroll = true
+                    if (scrollLeft != this.scrollLeft) {
+                        this.reqId = requestAnimationFrame(animate)
+                    } else {
+                        this.reqId = null
+                        this.scrollLeft = null
+                        requestAnimationFrame(() => (this.innerScroll = false))
+                    }
+                }
+                animate()
+            }
+        })
+    }
+
+    /**
+     * 滚动事件 => 修正滑块位置
+     */
+    bindScroll() {
+        this.$container.addEventListener('scroll', () => {
+            if (this.thumbXActive || this.thumbYActive || this.innerScroll) return
+            if (this.hasXScrollbar) {
+                this.$thumbX.style.left = (this.$container.scrollLeft / this.maxScrollLeft) * this.thumbXMaxLeft + 'px'
+            }
+            if (this.hasYScrollbar) {
+                this.$thumbY.style.top = (this.$container.scrollTop / this.maxScrollTop) * this.thumbYMaxTop + 'px'
+            }
+        })
+    }
+
+    /**
+     * 观察容器大小
+     */
+    resizeObserver() {
+        this.$resizeObserver = new ResizeObserver(entries => {
+            let contentRect = entries[0].contentRect
+            if (!(contentRect.width || contentRect.height)) return
+            this.update()
+        })
+        this.$resizeObserver.observe(this.$container)
+        this.$resizeObserver.observe(this.$content)
+    }
+
+    /**
+     * 使用滚动值修正滑块
+     * 在 容器大小 或 内容大小 发生改变时调用
+     */
+    update() {
+        this.setThumbSize()
+        if (this.hasXScrollbar) {
+            this.$thumbX.style.left = (this.$container.scrollLeft / this.maxScrollLeft) * this.thumbXMaxLeft + 'px'
+        }
+        if (this.hasYScrollbar) {
+            this.$thumbY.style.top = (this.$container.scrollTop / this.maxScrollTop) * this.thumbYMaxTop + 'px'
+        }
+    }
+
+    /**
+     * html字符串 转 dom对象
+     * @param {*} html
+     * @returns
+     */
+    html2dom(html) {
+        let element = document.createElement('div')
+        element.innerHTML = html
+        let children = element.children
+        if (children.length <= 1) {
+            return children[0]
+        } else {
+            return children
+        }
+    }
+
+    /**
+     * 生成自定义样式
+     */
+    style() {
+        let content = `
+  /* 轨道 */
+  .x-scrollbar__track-x {
+    height: ${parseInt(this.thumbSize) * 2 + 4}px;
+  }
+  
+  .x-scrollbar__track-y {
+    width: ${parseInt(this.thumbSize) * 2 + 4}px;
+  }
+  
+  /* 滑块 */
+  .x-scrollbar__track-x > .x-scrollbar__thumb-x,
+  .x-scrollbar__track-y > .x-scrollbar__thumb-y {
+    background: ${this.thumbBackground};
+    border-radius: ${parseInt(this.thumbRadius || 0) != 5 ? parseInt(this.thumbRadius || 0) : parseInt(this.thumbSize)}px;
+  }
+  
+  .x-scrollbar__track-x > .x-scrollbar__thumb-x {
+    height: ${parseInt(this.thumbSize)}px;
+  }
+  
+  .x-scrollbar__track-y > .x-scrollbar__thumb-y {
+    width: ${parseInt(this.thumbSize)}px;
+  }
+  
+  /* 激活后大小 */
+  .x-scrollbar__track-x:hover > .x-scrollbar__thumb-x,
+  .x-scrollbar__track--draging > .x-scrollbar__thumb-x {
+    height: ${parseInt(this.thumbSize) * 2}px;
+  }
+  
+  .x-scrollbar__track-y:hover > .x-scrollbar__thumb-y,
+  .x-scrollbar__track--draging > .x-scrollbar__thumb-y {
+    width: ${parseInt(this.thumbSize) * 2}px;
+  }
+  
+  /* 鼠标移入轨道 || 拖动过程中 => 显示轨道 & 高亮滑块 */
+  .x-scrollbar__track-x:hover,
+  .x-scrollbar__track-y:hover,
+  .x-scrollbar__track-x.x-scrollbar__track--draging,
+  .x-scrollbar__track-y.x-scrollbar__track--draging {
+    background: ${this.trackBackground || 'transparent'};
+  }`
+
+        this.key = 'x-scrollbar-' + Math.abs(((1 + Math.random()) * Date.now()) | 0).toString(16)
+        this.$dom.setAttribute(this.key, '')
+        let style = this.html2dom(`<style ${this.key}></style>`)
+        content = content.replaceAll('\n.x-scrollbar', `\n[${this.key}] > .x-scrollbar`)
+        content = content.replaceAll(';', ' !important;')
+        style.innerHTML = content
+        document.querySelector('head').appendChild(style)
+    }
+}
+
+export default XScrollbar

+ 56 - 0
packages/components/basic/size-animation/index.vue

@@ -0,0 +1,56 @@
+<template>
+    <div class="ui-size-animation" :class="{ ready, show: max !== 0, [animationStyle]: animationStyle }" :style="origin && { 'max-height': max + 'px' }" ref="contentRef">
+        <slot></slot>
+    </div>
+</template>
+
+<script setup>
+import { changeWHFactory } from '../../utils'
+import { defineExpose, ref, watchEffect, defineProps } from 'vue'
+
+const props = defineProps({
+    attr: {
+        type: String,
+        default: 'height',
+    },
+    animationStyle: {
+        type: String,
+        default: 'height',
+    },
+})
+
+const [contentRef, changeShow, max, origin, show, ready, refer] = changeWHFactory(false, props.attr)
+
+const animation = ref(false)
+
+watchEffect(() => {
+    const dom = contentRef.value
+    if (dom) {
+        const startHandler = () => (animation.value = true)
+        const endHandler = () => (animation.value = false)
+
+        dom.addEventListener('transitionstart', startHandler)
+        dom.addEventListener('transitionend', endHandler)
+        return () => {
+            dom.removeEventListener('transitionstart', startHandler)
+            dom.removeEventListener('transitionend', endHandler)
+        }
+    }
+})
+
+defineExpose({
+    changeShow: (setShow, ...args) => {
+        if (show !== setShow) {
+            animation.value = true
+        }
+        return changeShow(setShow, ...args)
+    },
+    show,
+    animation,
+    ready,
+    refer,
+})
+</script>
+<script>
+export default { name: 'UiSizeAnimation' }
+</script>

+ 49 - 0
packages/components/basic/slide/index.vue

@@ -0,0 +1,49 @@
+<template>
+    <div class="ui-slide">
+        <Gate :index="index">
+            <GateContent v-for="(item, i) in items" :key="i">
+                <slot :raw="item" :active="items[index]"></slot>
+            </GateContent>
+        </Gate>
+        <template v-if="showCtrl">
+            <span class="left" @click="prevHandler" v-if="index !== 0"><UIIcon type="left" /></span>
+            <span class="right" @click="nextHandler" v-if="index !== items.length - 1"><UIIcon type="pull-more" /></span>
+        </template>
+        <slot name="attach" :active="items[index]"></slot>
+    </div>
+</template>
+
+<script setup>
+import { Gate, GateContent } from '../gate'
+import { defineProps, ref, watchEffect } from 'vue'
+import UIIcon from '../icon'
+
+const props = defineProps({
+    items: Array,
+    currentIndex: {
+        type: Number,
+        default: 0,
+    },
+    showCtrl: {
+        type: Boolean,
+    },
+})
+const index = ref()
+
+watchEffect(() => (index.value = props.currentIndex))
+
+const prevHandler = () => {
+    if (index.value > 0) {
+        index.value--
+    }
+}
+const nextHandler = () => {
+    if (index.value < props.items.length - 1) {
+        index.value++
+    }
+}
+</script>
+
+<script>
+export default { name: 'UiSlide' }
+</script>

+ 41 - 0
packages/components/basic/tree/TreeItem.vue

@@ -0,0 +1,41 @@
+<template>
+    <li class="ui-tree-item">
+        <div :class="{}" @click="toggle" @dblclick="makeFolder">
+            <i class="arrow iconfont icon-pull-down" :class="{ open: isOpen, visibility: isFolder }"></i>{{ item.name }}
+            <span v-if="isFolder">[{{ isOpen ? '-' : '+' }}]</span>
+        </div>
+        <ul v-show="isOpen" v-if="isFolder">
+            <ui-tree-item class="item" v-for="(child, index) in item.children" :key="index" :item="child" @make-folder="$emit('make-folder', $event)" @add-item="$emit('add-item', $event)" />
+            <li class="add" @click="$emit('add-item', item)">+</li>
+        </ul>
+    </li>
+</template>
+<script>
+import { defineComponent, ref, computed } from 'vue'
+export default defineComponent({
+    name: 'UiTreeItem',
+    components: {},
+    props: {
+        item: Object,
+    },
+    setup(props, _) {
+        const item = props.item.getValue()
+        const toggle = () => {
+            if (isFolder.value) {
+                isOpen.value = !isOpen.value
+            }
+        }
+        const makeFolder = () => {}
+        const isOpen = ref(false)
+        const isFolder = computed(() => item.children && item.children.length)
+
+        return {
+            // item,
+            toggle,
+            makeFolder,
+            isOpen,
+            isFolder,
+        }
+    },
+})
+</script>

+ 75 - 0
packages/components/basic/tree/index.vue

@@ -0,0 +1,75 @@
+<template>
+    <ul class="ui-tree" :style="style" :class="{ children: level > 1, stroke, flat: level > maxTab }">
+        <template v-for="(item, index) in data" :key="item.id || index">
+            <li
+                class="ui-tree-item"
+                :class="{
+                    alone: data.length === 1,
+                    put: animationsRef[index].value && !animationsRef[index].value.show,
+                }"
+                v-if="item.children && item.children.length"
+            >
+                <div class="ui-tree-content">
+                    <span :class="{ first: !index }" class="ui-tree-auxiliary" v-if="stroke && index !== item.children.lengt - 1"> </span>
+                    <span class="ui-tree-ctrl" :class="{ open: animationsRef[index]?.value?.show }" @click="animationsRef[index].value.changeShow()"> </span>
+                    <slot :row="item" :locals="[...locals, index]"></slot>
+                </div>
+                <UISizeAnimation animationStyle="scale" :ref="animationsRef[index]" class="ui-tree-item-child">
+                    <ui-tree :style="style" :stroke="stroke" :data="item.children" :max-tab="maxTab" :level="level + 1" :open="open" :locals="[...locals, index]">
+                        <template #default="slotData">
+                            <slot v-bind="slotData"></slot>
+                        </template>
+                    </ui-tree>
+                </UISizeAnimation>
+            </li>
+            <li class="ui-tree-item un-children" v-else>
+                <div class="ui-tree-content">
+                    <slot :row="item" :locals="[...locals, index]"></slot>
+                </div>
+                <div class="ui-tree-item-child" v-if="stroke"></div>
+            </li>
+        </template>
+    </ul>
+</template>
+
+<script>
+export default { name: 'UiTree' }
+</script>
+<script setup>
+// computed
+import { defineProps, ref, watchEffect, defineExpose } from 'vue'
+import UISizeAnimation from '../size-animation'
+
+const props = defineProps({
+    data: {
+        type: Array,
+        require: true,
+    },
+    locals: {
+        type: Array,
+        default: () => [],
+    },
+    level: {
+        type: Number,
+        default: 1,
+    },
+    maxTab: { type: Number },
+    open: { type: Boolean },
+    stroke: { type: Boolean },
+    style: { type: [Object, String] },
+})
+
+const animationsRef = ref(props.data.map(item => ref(null)))
+const changeShowAll = isOpen => {
+    for (let ranimationRef of animationsRef.value) {
+        ranimationRef && ranimationRef.value?.changeShow(isOpen)
+    }
+}
+
+watchEffect(() => changeShowAll(props.open))
+
+defineExpose({
+    openAll: () => changeShowAll(true),
+    closeAll: () => changeShowAll(false),
+})
+</script>