Write the Code. Change the World.

分类目录
5月 06

前边那个弹窗还有一些瑕疵,看得到的瑕疵。比如,使用贝塞尔曲线来模拟弹簧那种伸缩效果时,会露底。如果说是变魔术,用土话讲,那就是穿帮了。还有就是弹窗的动画的时间没有定义的地方。这次都加上了。先看看效果。

Code

popupLayout.vue

<template>
    <view v-if="controller.open" class="popup-layer" catchtouchmove="true">
        <view v-if="direction == 'top'" class="top-bg-color" :class="{ 'top-bg-show': controller.bg, 'top-bg-hide': !controller.bg }" :style="{ opacity: controller.bgOpacity }" @tap="close"></view>

        <view v-if="direction == 'bottom'" class="bottom-bg-color" :class="{ 'bottom-bg-show': controller.bg, 'bottom-bg-hide': !controller.bg }" :style="{ opacity: controller.bgOpacity }" @tap="close"></view>

        <view v-if="direction == 'left'" class="left-bg-color" :class="{ 'left-bg-show': controller.bg, 'left-bg-hide': !controller.bg }" :style="{ opacity: controller.bgOpacity }" @tap="close"></view>

        <view v-if="direction == 'right'" class="right-bg-color" :class="{ 'right-bg-show': controller.bg, 'right-bg-hide': !controller.bg }" :style="{ opacity: controller.bgOpacity }" @tap="close"></view>

        <view class="popup-content" :style="_location">
            <slot></slot>

            <view v-if="timingFun == 'cubic-bezier' && contentBgColor" :style="{background:contentBgColor}" :class="'content-bg-' + direction"></view>
        </view>
    </view>
</template>

<script setup>
import { reactive, defineProps, defineEmits, defineExpose, computed, onMounted, watch } from 'vue';

const controller = reactive({
    open: false,
    lock: false,
    bg: false,
    bgOpacity: 0,
    translate: -100,
    time: null
});

const emits = defineEmits(['onClose']);

const timingFuns = {
    'cubic-bezier': 'transition-timing-function: cubic-bezier(.15,-0.88,.41,1.68);',
    ease: 'transition-timing-function: easy;',
    'ease-in': 'transition-timing-function: ease-in;',
    'ease-out': 'transition-timing-function: ease-out;',
    'ease-in-out': 'transition-timing-function: ease-in-out;',
    linear: 'transition-timing-function: linear;'
};

const props = defineProps({
    direction: {
        type: String,
        default: 'top' // 方向  top,bottom,left,right
    },
    bgOpacity: {
        type: Number,
        default: 0.38
    },
    timingFun: {
        type: String,
        default: 'cubic-bezier'
    },
    contentBgColor: {
        type: String,
        default: ''
    },
    transitionDuration: {
        type: String,
        default: '0.5s'
    }
});

watch(() => props.timingFun, (newValue, oldValue) => {}, {
    immediate: true
});

const _translate = computed(() => {
    const transformObj = {
        top: `transform:translateY(${-controller.translate}%)`,
        bottom: `transform:translateY(${controller.translate}%)`,
        left: `transform:translateX(${-controller.translate}%)`,
        right: `transform:translateX(${controller.translate}%)`
    };
    return transformObj[props.direction];
});

const _location = computed(() => {
    const positionValue = {
        top: 'bottom:0px;width:100%;',
        bottom: 'top:0px;width:100%;',
        left: 'right:0px;top:0px;height:100%;',
        right: 'left:0px;top:0px;height:100%;'
    };

    let style = `transition-duration:${props.transitionDuration};`;
    if (timingFuns[props.timingFun]) {
        style += timingFuns[props.timingFun];
    }
    return style + positionValue[props.direction] + _translate.value;
});

const open = function() {
    if (controller.open) {
        return;
    }

    controller.open = true;

    let openTimer = setTimeout(() => {
        clearTimeout(openTimer);
        controller.lock = true;
        controller.bg = true;
        controller.bgOpacity = props.bgOpacity;
        controller.translate = -0.1;
        openTimer = null;
    }, 100);

    // 防止误触
    let lockTimer = setTimeout(() => {
        clearTimeout(lockTimer);
        controller.lock = false;
        lockTimer = null;
    }, 500);
};

const close = function() {
    if (controller.timer || controller.lock) {
        return;
    }

    controller.bgOpacity = 0;
    controller.bg = false;
    controller.translate = -100;

    controller.timer = setTimeout(() => {
        clearTimeout(controller.timer);
        controller.open = false;
        controller.timer = null;
        emits('onClose');
    }, 500);
};

defineExpose({
    open,
    close
});
</script>

<style lang="scss" scoped>
.popup-layer {
    width: 100vw;
    height: 100vh;
    overflow: hidden;
    top: 0px;
    left: 0px;
    position: fixed;
    z-index: 9999;

    .popup-content {
        position: absolute;
        z-index: 1000000;
        transition: transform;
    }

    .content-bg-top {
        position: absolute;
        width: 100%;
        height: 108rpx;
        left: 0;
        bottom: -100rpx;
    }

    .content-bg-bottom {
        position: absolute;
        width: 100%;
        height: 108rpx;
        left: 0;
        top: -100rpx;
    }

    .content-bg-left {
        position: absolute;
        width: 108rpx;
        height: 100%;
        top: 0;
        right: -100rpx;
    }

    .content-bg-right {
        position: absolute;
        width: 108rpx;
        height: 100%;
        top: 0;
        left: -100rpx;
    }
}

// 底部出来
.top-bg-color {
    width: 120vw;
    height: 120vh;
    background: #000;
    position: relative;
    left: -10vw;
    top: 121vh;
    opacity: 0;
    transition: 0.24s opacity, 0.24s top;
}

.top-bg-show {
    top: 0;
}

.top-bg-hide {
    top: 121vh;
}

.top-bg-color:after {
    content: '';
    width: 120vw;
    height: 120vw;
    border-radius: 50%;
    background-color: #000;
    position: absolute;
    left: 0;
    top: -30vw;
}

// 顶部出来
.bottom-bg-color {
    width: 120vw;
    height: 120vh;
    background: #000;
    position: relative;
    left: -10vw;
    bottom: 121vh;
    opacity: 0;
    transition: 0.24s opacity, 0.24s bottom;
}

.bottom-bg-show {
    bottom: 0;
}

.bottom-bg-hide {
    bottom: 121vh;
}

.bottom-bg-color:after {
    content: '';
    width: 120vw;
    height: 120vw;
    border-radius: 50%;
    background-color: #000;
    position: absolute;
    left: 0;
    bottom: -30vw;
}

// 右边出来
.left-bg-color {
    width: 120vw;
    height: 120vh;
    background: #000;
    position: relative;
    top: -10vh;
    left: 121vw;
    opacity: 0;
    transition: 0.24s opacity, 0.24s left;
}

.left-bg-show {
    left: 0;
}

.left-bg-hide {
    left: 121vw;
}

.left-bg-color:after {
    content: '';
    width: 120vh;
    height: 120vh;
    border-radius: 50%;
    background-color: #000;
    position: absolute;
    top: 0;
    left: -30vh;
}

// 左边边出来
.right-bg-color {
    width: 120vw;
    height: 120vh;
    background: #000;
    position: relative;
    top: -10vh;
    right: 121vw;
    opacity: 0;
    transition: 0.24s opacity, 0.24s left;
}

.right-bg-show {
    right: 0;
}

.right-bg-hide {
    right: 121vw;
}

.right-bg-color:after {
    content: '';
    width: 120vh;
    height: 120vh;
    border-radius: 50%;
    background-color: #000;
    position: absolute;
    top: 0;
    right: -30vh;
}
</style>
5月 06

在项目中,如果说什么组件用的最多。那弹窗觉得是第一个。想要一个好的弹窗,也是要花一些心思。这里从这几个方面来说。

  1. 展示效果好看。
  2. 弹窗一般分背景底色和弹窗实体。怎么做到好看呢。底色要渐变出来,底色要像子弹一样有一个圆头,刷一样出来好看。
  3. 弹窗实体怎么好看。可以先快速再慢速的展示出来。也可以像弹簧一样来回动弹几下出来。
  4. 弹出的方向可配置,背景颜色,透明度可配置。
  5. 弹窗实体中,内容太高或太宽怎么处理呢。
  6. 总之,就是要做的好看。

只有想到了才会去做到,去实现。那么就一点点实现吧。

实现过程

在实现这些效果中, css 占据了很重要的一部分。比如渐变、伸出。

实现渐变可以用 opacity、rgba。

想实现伸出弹出可以用 transform、keyframes,甚至绝对定位。

那想要一个好的弹射效果,先想到的可能会是 keyframes。它可以很精细的对目标的状态进行更改操作。可是吧,transform 可以使用 step 和 cubic-bezier(贝塞尔曲线),也可以实现弹簧的那种效果。这样实现起来就简单好多了(代码要少要少要干净),但是,觉得还是不够细腻,不够好。

先看看效果吧。

出了 css,其他就是 vue3 相关的东东了。下边直接贴出代码。

这里有个缺点,就是使用贝塞尔曲线来做弹出效果时候,底端会露白。其实,也想到了一个方法。传一个实体的背景色过来,在背景那边补上颜色也是可以解决的。

Code

popupLayer.vue

<template>
    <view v-if="controller.open" class="popup-layer" catchtouchmove="true">
        <view v-if="direction == 'top'" class="top-bg-color" :class="{ 'top-bg-show': controller.bg, 'top-bg-hide': !controller.bg }" :style="{ opacity: controller.bgOpacity }" @tap="close"></view>

        <view v-if="direction == 'bottom'" class="bottom-bg-color" :class="{ 'bottom-bg-show': controller.bg, 'bottom-bg-hide': !controller.bg }" :style="{ opacity: controller.bgOpacity }" @tap="close"></view>

        <view v-if="direction == 'left'" class="left-bg-color" :class="{ 'left-bg-show': controller.bg, 'left-bg-hide': !controller.bg }" :style="{ opacity: controller.bgOpacity }" @tap="close"></view>

        <view v-if="direction == 'right'" class="right-bg-color" :class="{ 'right-bg-show': controller.bg, 'right-bg-hide': !controller.bg }" :style="{ opacity: controller.bgOpacity }" @tap="close"></view>

        <view class="popup-content" :style="_location"><slot></slot></view>
    </view>
</template>

<script setup>
import { reactive, defineProps, defineEmits, defineExpose, computed, onMounted, watch } from 'vue';

const controller = reactive({
    open: false,
    lock: false,
    bg: false,
    bgOpacity: 0,
    translate: -100,
    time: null
});

const emits = defineEmits(['onClose']);

const timingFuns = {
    'cubic-bezier': 'transition-timing-function: cubic-bezier(.15,-0.88,.41,1.68);',
    ease: 'transition-timing-function: easy;',
    'ease-in': 'transition-timing-function: ease-in;',
    'ease-out': 'transition-timing-function: ease-out;',
    'ease-in-out': 'transition-timing-function: ease-in-out;',
    linear: 'transition-timing-function: linear;'
};

const props = defineProps({
    direction: {
        type: String,
        default: 'top' // 方向  top,bottom,left,right
    },
    bgOpacity: {
        type: Number,
        default: 0.38
    },
    timingFun: {
        type: String,
        default: 'cubic-bezier'
    }
});

watch(() => props.timingFun, (newValue, oldValue) => {}, {
    immediate: true
});

const _translate = computed(() => {
    const transformObj = {
        top: `transform:translateY(${-controller.translate}%)`,
        bottom: `transform:translateY(${controller.translate}%)`,
        left: `transform:translateX(${-controller.translate}%)`,
        right: `transform:translateX(${controller.translate}%)`
    };
    return transformObj[props.direction];
});

const _location = computed(() => {
    const positionValue = {
        top: 'bottom:0px;width:100%;',
        bottom: 'top:0px;width:100%;',
        left: 'right:0px;top:0px;height:100%;',
        right: 'left:0px;top:0px;height:100%;'
    };

    let style = '';
    if (timingFuns[props.timingFun]) {
        style = timingFuns[props.timingFun];
    }
    return style + positionValue[props.direction] + _translate.value;
});

const open = function() {
    if (controller.open) {
        return;
    }

    controller.open = true;

    let openTimer = setTimeout(() => {
        clearTimeout(openTimer);
        controller.lock = true;
        controller.bg = true;
        controller.bgOpacity = props.bgOpacity;
        controller.translate = -0.1;
        openTimer = null;

        console.log('_location', _location);
    }, 100);

    // 防止误触
    let lockTimer = setTimeout(() => {
        clearTimeout(lockTimer);
        controller.lock = false;
        lockTimer = null;
    }, 500);
};

const close = function() {
    if (controller.timer || controller.lock) {
        return;
    }

    controller.bgOpacity = 0;
    controller.bg = false;
    controller.translate = -100;

    controller.timer = setTimeout(() => {
        clearTimeout(controller.timer);
        controller.open = false;
        controller.timer = null;
        emits('onClose');
    }, 600);
};

defineExpose({
    open,
    close
});
</script>

<style lang="scss" scoped>
.popup-layer {
    width: 100vw;
    height: 100vh;
    overflow: hidden;
    top: 0px;
    left: 0px;
    position: fixed;
    z-index: 9999;

    .popup-content {
        position: absolute;
        z-index: 1000000;
        transition: transform 0.4s;
    }
}

// 底部出来
.top-bg-color {
    width: 120vw;
    height: 120vh;
    background: #000;
    position: relative;
    left: -10vw;
    top: 121vh;
    opacity: 0;
    transition: 0.24s opacity, 0.24s top;
}

.top-bg-show {
    top: 0;
}

.top-bg-hide {
    top: 121vh;
}

.top-bg-color:after {
    content: '';
    width: 120vw;
    height: 120vw;
    border-radius: 50%;
    background-color: #000;
    position: absolute;
    left: 0;
    top: -30vw;
}

// 顶部出来
.bottom-bg-color {
    width: 120vw;
    height: 120vh;
    background: #000;
    position: relative;
    left: -10vw;
    bottom: 121vh;
    opacity: 0;
    transition: 0.24s opacity, 0.24s bottom;
}

.bottom-bg-show {
    bottom: 0;
}

.bottom-bg-hide {
    bottom: 121vh;
}

.bottom-bg-color:after {
    content: '';
    width: 120vw;
    height: 120vw;
    border-radius: 50%;
    background-color: #000;
    position: absolute;
    left: 0;
    bottom: -30vw;
}

// 右边出来
.left-bg-color {
    width: 120vw;
    height: 120vh;
    background: #000;
    position: relative;
    top: -10vh;
    left: 121vw;
    opacity: 0;
    transition: 0.24s opacity, 0.24s left;
}

.left-bg-show {
    left: 0;
}

.left-bg-hide {
    left: 121vw;
}

.left-bg-color:after {
    content: '';
    width: 120vh;
    height: 120vh;
    border-radius: 50%;
    background-color: #000;
    position: absolute;
    top: 0;
    left: -30vh;
}

// 左边边出来
.right-bg-color {
    width: 120vw;
    height: 120vh;
    background: #000;
    position: relative;
    top: -10vh;
    right: 121vw;
    opacity: 0;
    transition: 0.24s opacity, 0.24s left;
}

.right-bg-show {
    right: 0;
}

.right-bg-hide {
    right: 121vw;
}

.right-bg-color:after {
    content: '';
    width: 120vh;
    height: 120vh;
    border-radius: 50%;
    background-color: #000;
    position: absolute;
    top: 0;
    right: -30vh;
}
</style>

测试调用的临时 vue

<template>
    <view class="d-flex flex-column w-100">
        <view class="flex-column mt-10 px-30">
            <view class="text-color-3 text-size-30 font-w600">设置方向</view>

            <view class="d-flex align-items-center flex-wrap mt-10">
                <view v-for="(item, index) in directions" :key="index" class="mr-10 mt-10" hover-class="hover-opacity" @tap="setDirections(item)">
                    <view class="d-flex px-16 py-6 border-radius-8 flex-shrink-0" :class="{ 'bg-color-1 text-color-2': item == popupOption.direction, 'bg-color-2 text-color-4': item != popupOption.direction}">
                        <view class="text-size-26"> {{ item }} </view> 
                    </view>
                </view>
            </view>

            <view class="text-color-3 text-size-30 font-w600 mt-30">设置弹射</view>

            <view class="d-flex align-items-center flex-wrap mt-10">
                <view v-for="(item, index) in timingFuns" :key="index" class="mr-10 mt-10" hover-class="hover-opacity" @tap="setTimingFuns(item)">
                    <view class="d-flex px-16 py-6 border-radius-8 flex-shrink-0 action-item" :class="{ 'bg-color-1 text-color-2': item == popupOption.timingFun, 'bg-color-2 text-color-4': item != popupOption.timingFun}">
                        <view class="text-size-26"> {{ item}} </view> 
                    </view>
                </view>
            </view>

            <view hover-class="hover-opacity" class="bg-color-1 text-color-2 text-size-30 border-radius-16 py-20 mt-30 d-flex justify-content-center" @tap="doPopup">开始弹出</view>
        </view>
    </view>

    <popupLayer ref="layer" :direction="popupOption.direction" :timing-fun="popupOption.timingFun" @onClose="onClose">
        <view class="box-shadow-sm" :class="boxClass"></view>
    </popupLayer>
</template>

<script setup>

import { ref, reactive } from 'vue'

const popupOption = reactive({
    'direction': 'top',
    'timingFun': 'cubic-bezier'
});

const directions = ['top', 'bottom', 'left', 'right'];

const timingFuns = ['cubic-bezier', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear'];

const layer = ref(null);

const boxClass = ref('bottom-box');

const setDirections = function(value) {
    popupOption.direction = value;

    boxClass.value = { 'bottom': 'top-box', 'top': 'bottom-box', 'left': 'right-box', 'right': 'left-box'}[value]
}

const setTimingFuns = function(value) {
    popupOption.timingFun = value;
}

const doPopup = function() {
    layer.value.open()
}

const onClose = function() {
    console.log('close')
}
</script>

<style>
    .bottom-box {
        width: 100%;
        height: 520upx;
        border-top-left-radius: 20upx;
        border-top-right-radius: 20upx;
        background-color: #ffffff;
    }

    .top-box {
        width: 100%;
        height: 520upx;
        border-bottom-left-radius: 20upx;
        border-bottom-right-radius: 20upx;
        background-color: #ffffff;
    }

    .left-box {
        width: 520upx;
        height: 100vh;
        border-top-right-radius: 20upx;
        border-bottom-right-radius: 20upx;
        background-color: #ffffff;
    }

    .right-box {
        width: 520upx;
        height: 100vh;
        border-top-left-radius: 20upx;
        border-bottom-left-radius: 20upx;
        background-color: #ffffff;
    }
</style>

关于测试用的 vue,里边用到了很多 class,这些 class 都是全局定义好的。足够规范和足够包容就可以少写很多样式了。这样,有一个缺点就是 html 部分特别长。这就是组合起来的好处和缺点。也贴出部分代码。

base.scss

// 布局相关的样式
.d-none {
    display: none;
}

// flex 布局(全用 flex 布局)
.d-flex {
    display: flex;
}

.d-inline-flex {
    display: inline-flex;
}

.flex-row {
    flex-direction: row;
}

.flex-row-reverse {
    flex-direction: row-reverse;
}

.flex-column {
    flex-direction: column;
}
……
// margin
$list: 6 8 12 16 24;

// left
@each $i in $list {
    .ml-#{$i} {
        margin-left: #{$i}upx;
    }
}

@for $i from 1 to 5 {
    .ml-#{$i * 10} {
        margin-left: #{$i * 10}upx;
    }
}

// right
@each $i in $list {
    .mr-#{$i} {
        margin-right: #{$i}upx;
    }
}

@for $i from 1 to 5 {
    .mr-#{$i * 10} {
        margin-right: #{$i * 10}upx;
    }
}

// x
@each $i in $list {
    .mx-#{$i} {
        margin-left: #{$i}upx;
        margin-right: #{$i}upx;
    }
}

@for $i from 1 to 5 {
    .mx-#{$i * 10} {
        margin-left: #{$i * 10}upx;
        margin-right: #{$i * 10}upx;
    }
}

$num: 12;
@while $num < 54 {
    .text-size-#{$num} {
        font-size: #{$num}upx;
    }
    $num: $num + 2;
}

$num: 6;
@while $num < 30 {
    .border-radius-#{$num} {
        border-radius: #{$num}upx;
    }
    $num: $num + 2;
}

还有 css 的变量写法也要用起来。好东西就要用起来,有了变量,才算是真正的程序啊,哈哈。

root: {
--bg-color: #f00;
}

span {
background: var(--bg-color)
}

最后

其实,这里没有对超过宽度或超过高度的处理。这里仅仅是个弹出的工具,仅仅做弹出而已。实体部分都由插槽交给需要用到的地方。

如果真遇到宽度或高度超过的情况,可以套一层 scroll-view 组件。如果实体内容具有可变性,就是有时候会高一点有时候会矮一点(数据驱动ui,数据来源服务器),这个时候给 scroll-view可以加个约束,比如 max-height:calc(100vh - 100px),具体多少,试着来。

相关文章

https://developer.mozilla.org/zh-CN/docs/Web/CSS/transition-timing-function

https://zhuanlan.zhihu.com/p/397089242

https://cubic-bezier.com/#.23,-0.47,.44,1.65

https://juejin.cn/post/6844903688029339655

https://juejin.cn/post/6972720564301463565

5月 05

vue3 出来那么久了。以前的项目 vue2 也该更更更了。新项目那必须得 vue3 了。不过,有些关键的写法,会有些不同。一点点的来。

globalProperties

在 vue2,想定义一个全局的属性(方法或变量),只需要定义在 Vue.prototype 上就可以。然后在 template 中可以直接使用,在 script 中,通过 this 来访问使用。

到了 vue3 这里,就不一样了。这些方法和属性定义在 globalProperties, 就是 app.config.globalProperties 上。

比如:

import App from './App'

import {
    createSSRApp
} from 'vue'

const app = createSSRApp(App)

app.config.globalProperties.$test = (value) => {
    console.log('test ' + value)
}

vue3 是干掉 this 的。使用的时候这样。

import { onMounted, getCurrentInstance } from 'vue'

onMounted(() => {
    const { proxy,  ctx } = getCurrentInstance()
    proxy.$test('全局调用')
})

template 中,则可以直接使用。 getCurrentInstance 还有一个输出 ctx,这个和 vue2 时候的 this 一个样。

4月 15

css 是没有父元素选择器的。可是现在有伪选择器 has,这个可以实现父选择器的愿望。通过 has 这个词,也很容易记住和理解。就像一个反向思维一样,本来需要一个父级,那如果我父级就在那,我判断是否有子集不就通了吗。而 css 从来都是从父到子到兄的。

https://developer.mozilla.org/zh-CN/docs/Web/CSS/:has

为什么要搞这玩意呢。还是因为在使用 element-ui 的上传组件时,当用户选择了一张图片后,上传按钮还是显示在那地方。这样体验很差也不好看。即使你加了 limit:1 的控制也不行。不知道为啥会这样(我没找到对应的 api 还是就是这样呢)。

既然这样了,那总得处理。最直接的一种方式是通过 js + css 的方式。对于 html,只要 js + css 是没有搞不定的。可是这样代码不好看了啊,也增加了多余的逻辑。通过观察组件的 dom 结构,可以发现,不管是否有选择的图片,上传按钮的兄弟级存在一个 li标签。 只要选择了图片或默认存在的图片,都会存在一个
样式为 .el-upload-list__item的 div。那好,这个时候就想到,如果存在父选择器,再通过父选择器的兄弟选择,通过 display:none,不就可以隐藏了吗。
可是,可是就是没有父选择器啊。可是,可是 has 伪选择器就是可以实现这样一个场景就是父选择器。

下边是 element ui 的上传组件的结构(精简模拟的)

# 没选择图片的
<div class="upload-card">
    <li></li>
    <div class="upload-btn">

    </div>
</div>

# 选择图片的
<div  class="upload-card">
    <li>
    <div class="el-upload-list__item"></div>
    </li>
    <div class="upload-btn">

    </div>
</div>

通过 has 就可以实现隐藏了。

.upload-card:has(.el-upload-list__item) .upload-btn {
    display:none;
}
 ```

 ### 真实 code

 ```
 #template
 <el-form-item label="封面" prop="cover">
 <el-upload ref="coverUpload" class="upload-card" action="#" :multiple="false"  list-type="picture-card" :file-list="image.cover" :http-request="uploadCover" :auto-upload="false">
 <i class="el-icon-plus" />
 </el-upload>
</el-form-item>

# scss
::v-deep .upload-card:has(.el-upload-list__item) .el-upload--picture-card {
  display: none !important;
}
3月 30

瀑布流是一种展现方式。无论 h5 还是小程序,在一定场景下会需要。可以通过把数据分组,再 flex-column 布局。也可以用过计算浮动来实现布局。这些搞起来,还是比较麻烦,而且代码不好看。现在微信小程序实现瀑布流都新的方式。还有吸顶等操作。

开始

https://developers.weixin.qq.com/community/develop/article/doc/000ae631788f400c9a7f151f65b013

https://developers.weixin.qq.com/community/develop/article/doc/000a088c5c471062bf0f0af1a5b813

文章尾部,有代码测试片段。打开,就可以尝试。不过,需要将渲染方式切换到 Skyline 。

切换操作看这里 https://developers.weixin.qq.com/miniprogram/dev/framework/runtime/skyline/migration/#%E5%BF%AB%E6%8D%B7%E5%88%87%E6%8D%A2%E5%85%A5%E5%8F%A3

计算的方式

https://juejin.cn/post/7066778804827619364

3月 20

通过给 div 定义 id 属性,然后在 a 标签中 href 设置定义好的 id,用户点击 a 标签,页面就可以滑到目标位置。只是这样,点击的时候,url 会出现变化。这里了还有一种方法。

html 跳转到指定的位置

 const goView = function() {
    document.querySelector("#roll_user").scrollIntoView(true);
 }

这种,是通过 js 调用方法来实现。优点是 url 地址不变,还可以设置更多的参数。显得安静丝滑。缺点是,如果刷新浏览器,位置会变化。

当然,还可以通过 js 用其他方式实现这种结果。只是不划算。

3月 14

使用 vite 来作为构建前端的工具,是有一些缺陷的。

However, it lacks some features that Laravel Mix supports, such as the ability to copy arbitrary assets into the build that are not referenced directly in your JavaScript application.

https://laravel.com/docs/10.x/vite

相关

https://github.com/laravel/vite-plugin/blob/main/UPGRADE.md#migrating-from-vite-to-laravel-mix

多语言相关

注册,登录这些页面,以及 request 错误的提示,都是英文的。想要中文的,就得有对应的语言配置。

https://laravel-lang.com/installation/

composer require laravel-lang/common --dev

php artisan lang:add zh_CN

php artisan lang:update

然后,在 config/app.php 中,将 lang 设置成 'zh_CN' 即可。

3月 12

Inertia 是为希望构建整体应用程序的人而构建的。他们通常更喜欢控制器和视图之间的紧密结合,但又希望使用现代客户端框架来构建其应用程序。

上边这个描述,的确是很贴切。比如 laravel 框架,自身使用的是 blade 模板来渲染页面。所有处理都是服务端完成。也可以在其中部分使用 vue、react这些。

Laravel 提供了两全其美的解决方案。Inertia 可以桥接您的 Laravel 应用程序和现代 Vue 或 React 前端,使您可以使用 Vue 或 React 构建完整的现代前端,同时利用 Laravel 路由和控制器进行路由、数据注入和身份验证 - 所有这些都在单个代码存储库中完成。使用这种方法,您可以同时享受 Laravel 和 Vue / React 的全部功能,而不会破坏任何一种工具的能力。

https://laravel.com/docs/10.x/frontend

而 Inertia.js ,则是可以作为一个胶水的目的,就是服务端还是那个服务端,使用 vue,react,svelte 还是一样使用。但这些都是服务端来完成的。传统的 vue, react,svelte 这些(非 ssr 实现的),都是在前端处理,比如状态,页面 dom等都是在前端实现的。

官网: https://inertiajs.com/who-is-it-for

几年前构建的 demo: http://demo.inertiajs.com/login

中文文档(这个文档太旧了,当前最新版本是 1.0 版本):https://learnku.com/docs/inertia/0.11

相关

https://learnku.com/laravel/t/41051

3月 02

vue 这边是可以登录了。可是登录态没有保存。在这里,我们仅仅只要知道用户已经登录或已经注册了就好。然后再通过接口去获取用户信息,然后通过状态管理去处理这些信息。

这里不是通过 token 的方式维护状态。所以登录态仅仅加个标志和生命时间。想象一下,这就是一个独立的后台页面。结合 vue 的生态,它的流程是什么样子的呢。

  1. 判断用户是否登录过了。登录过了就直接请求用户信息。这里的用户信息包括用户自身的信息以及后台菜单的信息。所有权限角色都由后端控制和生成。如果还有其他配置信息,也可以连带或再单独发送请求。 这些请求是异步的,这些请求都在 auth 中间件的守护下。如果 cookie 是过期的,后端会返回 401,前端会重定向到登录页面。如果能正常拿到数据,那么就 next,渲染后台页面(左侧菜单,顶部,中间内容部分)。
  2. 用户没登录过。前端直接重定向到登录页面。

流程图如下:

先完成服务端逻辑

用户信息的获取

增加路由,路由应遵循 restful 的风格。通过请求方式 + 资源名 + 参数的方式。比如,如果是 put 请求,表示该请求是修改已存在的资源。

# routes/admin.php

Route::group([
    'middleware' => ['auth:sanctum'],
], function () {
    Route::get('userinfos', [UserController::class, 'getUserInfo'])->name('admin.api.getUserInfo');
});

增加控制器逻辑

# app/Http/Controllers/Admin/UserController
public function getUserInfo(Request $request)
{
    $user = $request->user();

    return response()->json(['user' => $user, 'menus' => []]);
}

这里可以直接拿到用户信息,是因为有 sanctum 中间件的守护。如果用户的认证未通过,在中间件环节就已经返回 401 了。这里是测试,所以菜单,直接给个空的数组。

json 没有指定 http状态码,默认就是 200。

完成 vue 的逻辑

vue 中的状态管理这里用 pinia。

删除默认的 stores/counter.js

增加 stores/userinfo.js

import { defineStore } from 'pinia'

export const useUserinfo = defineStore('userinfo', {
    state: () =>({
        isLogin: localStorage.getItem('isLogin') || false,
        name: '',
        email: ''
    }),
    actions: {
        login() {
            localStorage.setItem('isLogin', true)
            this.isLogin = true
        },
        loginOut() {
            localStorage.removeItem('isLogin')
            this.isLogin = false
        },
        setUserinfo(value) {
            const keys = ['name', 'email']
            keys.forEach(item => {
                if (value[item]) {
                    this[item] = value[item]
                } else {
                    this[item] = ''
                }
            })
        }
    }
})

现在的 pinia 是以前的 vuex 新版。用起来更方便,如果不是它的功能,感觉就自己组件内的调用。而且,也不像以前那样多很多 modules 的概念。

状态处理好了,我们现在来处理路由守护。将以前的路由里边加上守护逻辑就好。

router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import { useUserinfo } from '@/stores/userinfo.js'
import { GetUserinfo }from '@/api/request'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  base: '/admin/',
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('../views/home/index.vue')
    },
    {
      path: '/login',
      name: 'login',
      component: () => import('../views/user/login.vue')
    },
    {
      path: '/register',
      name: 'register',
      component: () => import('../views/user/register.vue')
    }
  ]
})

router.beforeEach(async (to, from, next) => {
  const whiteList = ['/login']
  const userinfo = useUserinfo()
  const isLogin = userinfo.isLogin
  if (isLogin) {
    GetUserinfo().then(
      res => {
        userinfo.setUserinfo(res.data.user)
        next()
      }
    ).catch(() => {
      userinfo.loginOut()
      next(`/login?redirect=${to.path}`)
    })
  } else {
    if (whiteList.some(value => value == to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})
export default router

路由守护做好了,我们只需要在登录成功后调用状态的 login 方法,并跳转到首页就好。

关于这个跳转,体验做到更好的话,不是非要强制跳转到首页的。比如本来用户是在看后台的用户信息,这个时候刚好 session 过期了,需要重新登录。登录成功后跳转到之前的页面,就是后台的用户信息页面才是最好的。

关于这个路由,我们可以在登录组件中,通过路由的监听获取到。

最后,我们修改下 header 组件。如果用户登录了,就显示用户的称呼。否则显示登录注册按钮。

<template>
    <header>
        <div>
      <n-avatar round size="large" @click="goHome">
        LOGO
      </n-avatar>
    </div>

        <div class="nav-bar">
            <n-space class="left-nav">
                <n-button quaternary @click="goHome">首页</n-button>
                <n-button quaternary>产品介绍</n-button>
                <n-button quaternary>关于我们</n-button>
            </n-space>

            <n-space v-if="userinfo.name">
                <span>{{ userinfo.name }}</span>
            </n-space>

            <n-space v-else class="right-nav">
                <n-button type="success" size="small" style="font-size: 12px;" @click="goLogin">登录</n-button>
                <n-button type="success" size="small" style="font-size: 12px" @click="goRegister">注册</n-button>
            </n-space>
        </div>
    </header>
</template>

<script setup>

import { useRouter } from 'vue-router'

import { useUserinfo } from '@/stores/userinfo.js'

const userinfo = useUserinfo()

const router = useRouter()

const goHome = () => {
    router.push({name: 'home'})
}

const goLogin = () => {
  router.push({name: 'login'})
}

const goRegister = () => {
  router.push({name: 'register'})
}
</script>

<style lang="scss" scoped>
header {
    display: flex;
    align-items: center;
    box-sizing: border-box;
    padding: 0 30px;
    width: 100%;
    height: 72px;
    background-color: #fff;
    box-shadow: 0 0.125rem 0.25rem #00000013 !important;

  .nav-bar {
    display: flex;
    align-items: center;
    width: 100%;
    box-sizing: border-box;
    padding-left: 50px;

    .left-nav {
      flex:1;
    }
  }
}
</style>

退出功能没做。这里就不做了,这里仅仅是 demo。不是真正的项目。

其实,注册按钮也是不需要的。后台我就不想让用户注册。还有登录这里,我其实也不想要用户输入,直接让用户扫码还更快。现在谁没个手机,现在谁没个微信。就是这么霸道,只要一个登录二维码就可以。

这里,我还是把注册相关的删掉吧。删除注册组件,删除注册按钮,删除注册路由信息。

好了。现在打包一下项目。拿到 php 那边去测试一下。

pnpm run build

效果如图所示。

到此,已经验证了。 项目主网站和vue做的spa后台网站,可以公用登录态。下一步就是做移动端(app、小程序等)的接口逻辑。这个接口适用 token 的方式来验证登录态,不再是 cookie和session的方式。

提交代码。清洗车子。收拾房子。

git add .
git commit -m '登录功能彻底完成'

git remote add origin https://gitlab.com/demo9885/vue3_backstage.git

git push -u origin main

代码仓库: https://gitlab.com/demo9885/vue3_backstage

这个只是一个简单的架构。离完整的项目还差很多。比如多语言,多主题(白天,晚上等),组件库,图表,大屏展示等等。

3月 02

服务端 admin/api 的注册登录逻辑处理。

开始

先创建基本的控制器和Request文件。

php artisan make:controller Admin/UserController

php artisan make:request Admin/AdminRequest

php artisan make:request Admin/RegisterRequest

php artisan make:request Admin/LoginRequest

我们先把 request 完成起来。 AdminRequest 作为其他 Request 的父类,需要做两件事。

一是 authorize 永远返回 true。这个是给接口用的,不是网页自己用。没必要去验证通过。验证的逻辑会在路由和中间件中完成。

二是重写 failedValidation 方法。默认的 failedValidation 会发起重定向。这里是接口,也是不需要的。

所以 AdminRequest.php 是这个样子。

<?php

namespace App\Http\Requests\Admin;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\JsonResponse;

class AdminRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    protected function failedValidation(Validator $validator)
    {
        throw new HttpResponseException(response()->json([
            'error' => (new ValidationException($validator))->errors()
        ], JsonResponse::HTTP_UNPROCESSABLE_ENTITY));
    }
}

再看其他 request。

#LoginRequest

<?php

namespace App\Http\Requests\Admin;

class LoginRequest extends AdminRequest
{
    public function rules(): array
    {
        return [
            'email' =>  ['required', 'string', 'email', 'max:255'],
            'password' => ['required', 'string', 'min:8'],
        ];
    }

    public function messages()
    {
        return [];
    }

    public function attributes()
    {
        return [
            'email' => '邮箱',
            'password' => '密码'
        ];
    }
}

# RegisterRequest

<?php

namespace App\Http\Requests\Admin;

class RegisterRequest extends AdminRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' =>  ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8'],
        ];
    }

    public function messages()
    {
        return [];
    }

    public function attributes()
    {
        return [
            'name' => '称呼',
            'email' => '邮箱',
            'password' => '密码'
        ];
    }
}

在 UserController.php 中创建登录注册接口。

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\LoginRequest;
use App\Http\Requests\Admin\RegisterRequest;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Auth;
use App\Models\User;

use Illuminate\Http\Request;

class UserController extends Controller
{
    public function login(LoginRequest $request)
    {
        $data = $request->only(['email', 'password']);

        $remember = true;

        if (Auth::attempt($data, $remember)) {
            $user = auth()->user();
            return response()->json($user);
        } else {
            return response()->json(['message' => '账号或密码错误'], 403);
        }
    }

    public function register(RegisterRequest $request)
    {
        $data = $request->only(['name', 'email', 'password']);

        $data['password'] = Hash::make($data['password']);

        $user = User::create($data);

        Auth::guard()->login($user);

        return response()->json($user);
    }
}

最后,看看路由。

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Admin\UserController;

// 登录
Route::post('login', [UserController::class, 'login'])->name('admin.api.login');

// 注册
Route::post('register', [UserController::class, 'register'])->name('admin.api.register');

csrf-token 路由是 Sanctum 自己提供的。我们只需要再 config/sanctum.php 中配置上前缀就好。

    …
    'prefix' => 'admin/api'

到了这里,服务端的逻辑算是好了。现在用之前创建的 vue 项目来测试登录注册接口。

我们先从简单的入手。那就是登录。登录页面和注册页面很香香。是这样子的。

<template>
    <div class="container">
        <div class="form-wrap">
            <n-card title="登录">
                <n-form
                    ref="formRef"
                    :model="model"
                    :rules="rules"
                    label-placement="left"
                    label-width="auto"
                    require-mark-placement="right-hanging"
                    :style="{
                        maxWidth: '640px'
                    }">
                    <n-form-item label="邮箱" path="email">
                        <n-input v-model:value="model.email" :placeholder="rules.email.message" />
                    </n-form-item>
                    <n-form-item label="密码" path="password">
                        <n-input type="password" v-model:value="model.password" :placeholder="rules.password.message" />
                    </n-form-item>
                    <n-form-item label=" " :show-feedback="false" class="login-item">
                        <n-button strong type="primary" @click="submit">登录</n-button>
                    </n-form-item>
                </n-form>
            </n-card>
        </div>
    </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { GetCsrfCookie, Login } from '@/api/request.js'

const router = useRouter()

const formRef = ref(null)

const model = reactive({
    email: null,
    password: null
})

const rules = {
    email: {
        required: true,
        trigger: ['blur', 'input'],
        message: '请输入邮箱'
    },
    password: {
        required: true,
        trigger: ['blur', 'input'],
        min: 8,
        message: '请输入密码'
    }
}

const submit = () => {
    formRef.value
        ?.validate((errors) => {
            if (errors) {
                console.error(errors)
            }
        })
        .then(() => {
            doLogin()
        })
}

const doLogin = () => {
    GetCsrfCookie(null).then(() => {
        Login({ email: model.email.trim(), password: model.password.trim() }).then(() => {
            // 登录完成,就跳转到首页吧
            router.push({ name: 'home' })
        })
    })
}
</script>

<style lang="scss" scoped>
.container {
    display: flex;
    justify-content: center;

    .form-wrap {
        display: flex;
        margin: 30px 0;
        min-width: 640px;
    }

    .login-item {
        margin-top: 12px;
    }
}
</style>

好了,我们打个包,试一试。

pnpm run build

然后将打包好的 admin 这个文件夹移动到 laravel 项目的 public 下。

浏览器访问:http://ypb2.com/admin/

ok,显示正常。然后刷新也是没有问题的。再点点登录注册。也可以。好吧,那来登录吧。因为之前通过 laravel 已经注册过一个账号了,就用那个账号登录。

演示如下图:

通过这个可以看到 vue 编写的 spa 页面和 laravel 自己的页面,共同持有 cookie。保证了登录的一致性。

这样做的目的就是想 laravel 的页面是官网。 vue 写的是后台。后台和官网有各自的特点。也有对应的方式去实现(后台用 vue、react 这样实现起来比较好)。

到了这里,还不算完成。虽然 vue 页面登录了。但是对登录的用户信息没有保存处理。下一步,就是对登录的信息进行保存处理。