【UI库】picker组件

需求

  • 正确显示数据, 单例、多列、级联;
  • 可以滑动切换数据;
  • 滑动范围在第一个数据和最后一个数据中间;
  • 每次滑动停止时,高亮的值都能停留在最中间;
  • 滑动的过程中有惯性滑动;
  • 级联操作时,后一列的数据根据前一列高亮的数据切换;
  • onchange事件;
  • 组件在惯性滑动的过程中突然被关闭,需要告知父组件当前的取值;
  • 有默认值时,处在正确的高亮位置;
  • 可自定义每一项的高度;
  • 可自定义默认值对应的键名
  • 是否显示含有确认、取消、标题的工具栏;
  • 可自定义工具栏的确认、取消、标题的文字及颜色;
  • 可自定义工具栏的高度。

props

 @Prop({default: []}) private columns!: any[];
  @Prop({default: 44}) private itemHeight!: number | string;
  @Prop({default: 'name'}) private valueKey!: string;
  @Prop({default: ''}) private defaultValue!: string;
  @Prop({default: 'id'}) private defaultKey!: string;
  @Prop({default: '取消'}) private cancelText!: string;
  @Prop({default: '#5e6d82'}) private cancelColor!: string;
  @Prop({default: '确认'}) private confirmText!: string;
  @Prop({default: '#007bff'}) private confirmColor!: string;
  @Prop({default: '标题'}) private title!: string;
  @Prop({default: '44'}) private toolBarHeight!: number | string;
  @Prop({default: true}) private showToolBar!: boolean;

从0到1的开发过程

html结构

- 头部
- 筛选主体
	- 筛选列组件(for循环)
	- 高亮框(即中间两条线)
	- 遮罩蒙板,通过渐变的方式将上下变模糊一些
<template>
  <div class="ar-picker">
    <div class="ar-picker__columns">
      <div>列组件</div>
      <div class="ar-picker__mask"></div>
      <div class="ar-picker__frame"></div>
    </div>
  </div>
</template>
.ar-picker{
  position: relative;
  background: #fff;
  user-select: none;

  &__columns{
    position: relative;
    display: flex;
  }

  &__mask{
    position: absolute;
    top:0;
    bottom: 0;
    right: 0;
    left:0;
    z-index: 2;
    background-image: linear-gradient(
        180deg,
        hsla(0, 0%, 100%, 0.9),
        hsla(0, 0%, 100%, 0.4)
      ),
      linear-gradient(0deg, hsla(0, 0%, 100%, 0.9), hsla(0, 0%, 100%, 0.4));
    background-repeat: no-repeat;
    background-position: top, bottom;
    backface-visibility: hidden;
    pointer-events: none;
  }

  &__frame{
    position: absolute;
    top: 50%;
    right: 4px;
    left: 4px;
    z-index: 3;
    transform: translateY(-50%);
    pointer-events: none;
    &::after{
      content: '';
      position: absolute;
      box-sizing: border-box;
      top: -50%;
      right: -50%;
      bottom: -50%;
      left: -50%;
      border-top: 1px solid #ebedf0;
      border-bottom: 1px solid #ebedf0;
      transform: scale(0.5);
    }
  }
}

定义用户可自定义的参数

// picker的选项值 
@Prop({default: []}) private columns!: any[];
// 每项的高度
@Prop({default: 44}) private height!: number | string;
// 每项的显示的内容
@Prop({default: 'name'}) private valueKey!: string;
// 每项的被选中时返回的值
@Prop({default: 'id'}) private returnKey!: string;

设置pickercolumns的高度

本组件允许用户自定义每项的高度,所以我们要考虑传递过来的值为0的情况:

get optonHeight(){
   return this.height ? (+this.height) : DEFAULT_ITEM_HEIGHT;
}

设置columns的高度

在本组件中我们默认只显示5个,所以高度的计算为:每一项的高度 * 5:

const DEFAULT_ITEM_HEIGHT = 44;

get columnHeight(){
  return this.optonHeight * 5;
}

get columnsStyle(){
    return {
      height: `${this.columnHeight}px`
    }
}

设置高亮区域的高度

即每一项的高度

get frameStyle(){
    return {
      height: `${this.optonHeight}px`
    }
}

设置遮罩的大小

get maskStyle(){
    return {
      backgroundSize: `100% ${(this.columnHeight - this.optonHeight) / 2}px`
    }
}

处理columns的值

用户可以传递的columns有这些:

// 单例
columns: ['小明', '小红', '小刚'];                               // text
columns: [{ id: 1,  name: '小明' }, { id: 2, name: '小红' }];   // object

// 多列                               
columns: [
    [{ id: 1,  name: '小明' }, { id: 2, name: '小红' }], 
    [{ id: 'apple',  name: '苹果' }, { id: 'banner', name: '香蕉' }]  // object true
]

// 级联操作
columns: [
    {
        id: '1',
        name: '广东省',
        children: [
            {
                id: 'shenzhen',
                name: '深圳',
                children: [{
                    id: 'baoan',
                    name: '宝安区'
                },{
                    id: 'longhu',
                    name: '龙湖区'
                }]
            },
            {
                id: 'dongguan',
                name: '东莞',
                children: [{
                    id: 'guancheng',
                    name: '莞城'
                },{
                    id: 'songshanhu',
                    name: '松山湖'
                }]
            }
        ]
    },{
        id: '2',
        name: '湖南省',
        children: [
            {
                id: 'changsha',
                name: '长沙',
                children: [{
                    id: 'changshaxian',
                    name: '长沙县'
                },{
                    id: 'wangcheng',
                    name: '望城'
                }]
            },{
                id: 'yueyang',
                name: '岳阳',
                children: [{
                    id: 'yunxi',
                    name: '云溪区'
                },{
                    id: 'junshan',
                    name: '君山区'
                }]
            }
        ]
    }
]

我们的columns组件是呈现每一列的数据,所以我们需要将数据重构为N个(列)数组结构。

@Watch('columns', { deep: true, immediate: true})
  changeColumns(){
    this.formatColumn();
}

要分别处理单列(纯文本、对象)、多列和级联的情况,然后赋值给formattedColumns

private formatColumns(){
    const { columns, dataType } = this;

    switch(dataType){
      case 'text': this.formatText();break;
      case 'single': this.formattedColumns = [ columns ];break;
      case 'multi': this.formattedColumns = columns; break;
      case 'cascade': this.formatCascade();break;
      default: break;
    }
}

private formatText(){
    let formattedColumns = [];

    for(let i = 0, len = this.columns.length; i < len; i++){
      let obj: any = {};
      const key = this.defaultKey || 'id';
      obj[key] = i;
      obj.name = this.columns[i];
      formattedColumns.push(obj);
    }

    this.formattedColumns = [formattedColumns];
  }

private formatCascade(){
    let firstColumn: any[] = [];
    Object.keys(this.columns).forEach((key: any) => {
      firstColumn.push(this.columns[key]);
    })

    let formattedColumns = [];
    formattedColumns.push(firstColumn)
    let cursor = firstColumn[0].children;

    while(Array.isArray(cursor) && cursor.length){
      formattedColumns.push(cursor);
      cursor = cursor[0].children;
    }

    this.formattedColumns = formattedColumns;
}

column组件基本架构

props
@Prop({default: 44}) private itemHeight!: number | string;
@Prop({default: () => []}) private columnList!: any[];
布局

每一列作为单独的一个组件,即为picker_column, 所以每一列必有flex:1的样式,由于中间的内容是会滚动的,所以含有overflow: hidden的样式。内部含有ul, 我们通过更改它的tanslateY来改变哪一项位于picker的正中间,内部的li会根据用户提供的itemHeight决定,显示的内容需要水平垂直。

<template>
  <div class="ar-picker-column">
    <ul class="ar-picker-column__wrapper"
        ref="wrapper"
    >
      <li class="ar-picker-column__item"
          :style="optionHeight"
      >
        <div>每一项</div>
      </li>
    </ul>
  </div>
</template>
.ar-picker--column{
    flex:1;
    overflow: hidden;
    font-size: 14px;

    &__wrapper{
      transition-timing-function: cubic-bezier(0.23, 1, 0.68, 1);
    }

    &__item{
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0 8px;
    }
}

每一项的高度是由itemHight决定的:

get optionHeight(){
    return {
      height: `${this.itemHeight}px`
    };
}

数据渲染

接下来我要来实现一个大头,就是数据渲染,这里还得考虑到用户定义的高亮默认值。

<template>
  <div class="ar-picker-column">
    <ul class="ar-picker-column__wrapper"
        ref="wrapper"
    >
      <li class="ar-picker-column__item"
          :style="optionHeight"
          v-for="(item, index) in columnList"
          :key="index"
      >
        <div>{{item.name}}</div>
      </li>
    </ul>
  </div>
</template>
<template>
  <div class="ar-picker">
    <div class="ar-picker__columns"
        :style="columnsStyle"
    >
      <column v-for="(item, index) in formattedColumns"
              :key="index"
              :itemHeight="optonHeight"
              :columnList="item"
      />
      <div class="ar-picker__mask"
           :style="maskStyle"
      ></div>
      <div class="ar-picker__frame"
          :style="frameStyle"
      ></div>
    </div>
  </div>
</template>

以上的代码可以实现单列和多列的渲染,级联渲染虽然也可以,但是我们没有考虑用户定义了默认值的情况,现在第二列显示的是浙江的杭州和温州,第三轮显示的是杭州的西湖区和余杭区。如果用户希望初始化的时候显示的是福建的福州和厦门厦门的思明区和沧海区,那就有问题了。

默认取值
@Prop({default: 'fujian, xiamen, haicang'}) private defaultValue!: string;
@Prop({default: 'id'}) private defaultKey!: string;
private formatCascade(){
    let firstColumn: any[] = [];
    Object.keys(this.columns).forEach((key: any) => {
      firstColumn.push(this.columns[key]);
    })

    let formattedColumns = [];
    let cursor = firstColumn;
    formattedColumns.push(firstColumn);
    const defaultValueArr = this.defaultValue.split(',');
    let defaultValueArrIndex = 0;
    
    while(cursor && Array.isArray(cursor) && cursor.length > 0 && cursor[0].children){
      console.log(cursor, 'cursor');
      let children = this.getChildColumn(cursor, defaultValueArr[defaultValueArrIndex]);
      formattedColumns.push(children);
      cursor = children;
      defaultValueArrIndex++;
    }

    this.formattedColumns = formattedColumns;
  }

  private getChildColumn(column: any[], key: string): any[]{
    key = key.trim();
    for(let i = 0, len = column.length; i < len; i++){
      if(column[i][this.defaultKey] === key){
        column = column[i].children ? column[i].children : [];
        return column;                                                                        
      }
    }
    return [];
  }

在这里插入图片描述

设置baseOffset和offset

初始化的时候,如果没有默认值,那么我们每一列第一项应该处于最中间,因为最多显示5个列表项,所以一开始的时候应该将ul偏移两个itemHeight的距离。

get baseOffset(){
    return (this.itemHeight * (5 - 1)) / 2;
}

url组件需要根据滑动时改变的offset来改变偏移量,因为这是动画,所以加一个动画过度时间duration:

private offset:number = 0;
private duration:number = 0;

get wrapperStyle(){
    return {
      transform: `translate3d(0, ${this.offset + this.baseOffset}px, 0)`,
      transitionDuration: `${this.duration}ms`,
      transitionProperty: 'all',
    }
}

在这里插入图片描述

因为可能有多列,所以把每一列封装成了一个组件。这个组件需要实现以下功能。

根据默认值设置高亮

index代表当前是第几列,defaultIndex代表当前列高亮哪一个项,在columns加上:

@Prop({default: 0}) private index!: number;
@Prop({default: () => []}) private defaultIndex!: any[];
private getDefaultIndex(){
    let defaultIndex = new Array(this.defaultValueArr.length);

    for(let i = 0, len = this.formattedColumns.length; i < len; i++ ){
      const list = this.formattedColumns[i];
      defaultIndex[i] = 0;
      for(let j = 0, jLen = list.length; j < jLen; j++){
        if(list[j][this.defaultKey] === this.defaultValueArr[i]){
          defaultIndex[i] = j;
        }
      }
    }
    this.defaultIndex = defaultIndex;
  }

  get defaultValueArr(){
    return this.defaultValue.split(',');
  }

defaultValue的值为:fujian, xiamen, haicang时,根据getDefaultIndex获得的defaultIndex的值为[1,1,1]

在使用column组件时,修改为:

<column v-for="(item, index) in formattedColumns"
              :key="index"
              :itemHeight="optonHeight"
              :columnList="item"
              :columnIndex="index"
              :defaultIndex="defaultIndex"
/>

这样我们在column组件中监听defaultIndex

@Watch('defaultIndex', { deep: true, immediate: true})
  changeDefaultIndex(newVal: any[]){
    let index = newVal[this.columnIndex] ?? 0;
    this.setIndex(index);
}

setIndex中,我们会根据index来更改offset的值,进而改变ul的偏移量,我们需要控制index的取值范围:

// 先获得当前可选项的个数
get count(){
	return this.columnList.length;
}
private adjust(index: number){
    return range(index, 0, this.count);
}

private setIndex(index: number){
    index = this.adjust(index) || 0;
    const offset = -index * this.itemHeight;
    
    this.offset = offset;
}
export const range = (num: number, min: number, max: number) => {
  return Math.min(Math.max(num, min), max-1);
}

这样fujian, xiamen, haicang就在初始化的时候高亮了。

在这里插入图片描述

滑动更新offset

接下来到了我们绑定touch事件实现picker触摸滑动的时间了。跟前两个组件一样,绑定touch相关事件的函数:

mounted(){
    this.bindTouchEvent(this.$el);
}

在开始的时候,我们触发touchStart,顺便把duration设置为0:

private startOffset: number = 0;
public handleTouchStart(event: TouchEvent){
    this.touchStart(event);
    this.startOffset = this.offset;
    this.duration = 0;
  }

在触摸过程中,不断更新offset,offset的取值应该在每项的高度和整个picker的高度之间:

public handleTouchMove(event: TouchEvent){
    this.toucheMove(event);
    this.offset = range(this.startOffset + this.deltaY, -(this.count * this.itemHeight), this.itemHeight);
  }

到这里就可以滑动了,虽然特别卡顿(卡成了安卓机哈哈哈),在触摸结束时,index的值应该在0和每一项高度的中间,我们更新一下offset的值:(setIndex多了个参数emitChange, 当为true时我们可以通知一下父组件picker changed,这对级联数据是有帮助的)

public handleTouchEnd(){
    const index = this.getIndexByOffset(this.offset);
    this.duration = DEFAULT_DURATION;
    this.setIndex(index, true);
}
private currentIndex: number = this.defaultIndex[this.columnIndex];

private setIndex(index: number, emitChange?: boolean){
    index = this.adjust(index) || 0;
    const offset = -index * this.itemHeight;
    
    const trigger = () => {
      if(index !== this.currentIndex){
        this.currentIndex = index;
        
        if(emitChange){
          this.$emit('change', {
            columnIndex: this.columnIndex,
            currentIndex: index,
            item: this.columnList[index]
          });
        }
      }
    }
    trigger();
    this.offset = offset;
  }

滑动停止时触发change事件

这里需要考虑我们的三种类型,当为单列或多列时都需要更新currentValue, 单列还会告诉用户当前选择了那一项的index,而级联的不仅需要更新currentValue还需要更新下一列的内容。

private onChange(obj: any){
    const { columnIndex, currentIndex, item } = obj;
    let currentValueArr = this.currentValue.split(',');
    const currentValue = item[this.defaultKey];

    if(this.dataType === 'text' || this.dataType === 'single'){
      this.currentValue = currentValue;
      this.$emit('change', Object.assign({...item}, {index: currentIndex}));
    }else if(this.dataType === 'multi'){
      currentValueArr[columnIndex] = currentValue;
      this.currentValue = currentValueArr.join(',');
      this.$emit('change', item);
    }else{
      this.onChangeCascade(columnIndex, currentIndex, currentValue);
    }
  }

我们需要修改一下原先的代码:

private currentValue: string = this.defaultValue;

get defaultValueArr(){
   return this.currentValue.split(',');
}

// 原先formatCascade中直接使用的是this.defaultValue获得的defaultValueArr,现在需要改为由currentValue得到的defaultValueArr
private formatCascade(){
    const defaultValueArr = this.defaultValueArr;
    let defaultValueArrIndex = 0;
    let firstColumn: any[] = [];

    Object.keys(this.columns).forEach((key: any, index: number) => {
      firstColumn.push(this.columns[key]);
    })

    let formattedColumns = [];
    let cursor = firstColumn;
    formattedColumns.push(firstColumn);
    
    while(cursor && Array.isArray(cursor) && cursor.length > 0 && cursor[0].children){
      let children = this.getChildColumn(cursor, defaultValueArr[defaultValueArrIndex]);
      formattedColumns.push(children);
      cursor = children;
      defaultValueArrIndex++;
    }

    this.formattedColumns = formattedColumns;
  }

// 原先是返回空数组,现在是返回当列的第一项
private getChildColumn(column: any[], key: string): any[]{
    key = key.trim();
    
    if(key.length !== 0){
      for(let i = 0, len = column.length; i < len; i++){
        if(column[i][this.defaultKey] === key){
          column = column[i].children ? column[i].children : [];
          return column;                                                                        
        }
      }
    }
    
    return column[0].children;
}

所以由上面的代码可得,级联操作的formattedColumns的内容是根据currentValue,故:

private onChangeCascade(columnIndex: number, currentIndex: number, currentValue: string){

    // 列表项取决于defaultValueArr,要更改defaultValueArr[columnIndex]等于defaultKey值
    let defaultValueArr = this.currentValue.split(',');
    if(columnIndex !== this.formattedColumns.length -1){

      // 切换某一列,后面的列都要改变,且每一个的值都为某列的第一个
      for(let i = columnIndex, len = this.formattedColumns.length; i < len; i++){
        if(i === columnIndex ) {
          const columns = this.formattedColumns[i];
          for(let j = 0, jLen = columns.length; j < jLen; j++){
            const value = columns[j][this.defaultKey];
            if(value == currentValue) defaultValueArr[columnIndex] = value;
          }
        }else{
          defaultValueArr[i] = '';
        }
      }

      this.currentValue = defaultValueArr.join(',');
      this.formatColumns();

    } else{
      defaultValueArr[columnIndex] = currentValue;
      this.currentValue = defaultValueArr.join(',');
    }
}

惯性滑动

到这里column组件就完成的差不多了,接下来我们来实现别人家的惯性滑动

惯性滑动思路:在手指离开屏幕时,如果和上一次 move 时的间隔小于 MOMENTUM_LIMIT_TIME 且 move 距离大于 MOMENTUM_LIMIT_DISTANCE 时,执行惯性滑动

const MOMENTUM_LIMIT_TIME = 300;
const MOMENTUM_LIMIT_DISTANCE = 15;

private touchStartTime: number = 0;
private momentumOffset: number = 0;
public handleTouchStart(event: TouchEvent){
    this.touchStart(event);

    this.startOffset = this.offset;
    this.duration = 0;
    this.touchStartTime = Date.now();
    this.momentumOffset = this.startOffset;
}
 public handleTouchMove(event: TouchEvent){
    this.toucheMove(event);
    this.offset = range(this.startOffset + this.deltaY, -(this.count * this.itemHeight), this.itemHeight);

    const now = Date.now();
    if(now - this.touchStartTime > MOMENTUM_LIMIT_TIME) {
      this.touchStartTime = now;
      this.momentumOffset = this.offset;
    }
  }
public handleTouchEnd(){
    const distance = this.offset - this.momentumOffset;
    const duration = Date.now() - this.touchStartTime;
    const allMomentum = duration < MOMENTUM_LIMIT_TIME && Math.abs(distance) > MOMENTUM_LIMIT_DISTANCE;

    // 惯性滑动思路:
    // 在手指离开屏幕时,如果和上一次 move 时的间隔小于 `MOMENTUM_LIMIT_TIME` 且 move
    // 距离大于 `MOMENTUM_LIMIT_DISTANCE` 时,执行惯性滑动
    if(allMomentum){
      this.momentum(distance, duration);
      return;
    }
    
    const index = this.getIndexByOffset(this.offset);
    this.duration = DEFAULT_DURATION;
    this.setIndex(index, true);
}

public momentum(distance: number, duration: number){
    const speed = Math.abs(distance / duration);
    distance = this.offset + (speed / 0.003) * (distance < 0 ? -1 : 1);
    const index = this.getIndexByOffset(distance);
    this.duration = +1000;
    this.setIndex(index, true);
}

加上确定和取消的操作栏

<template>
  <div class="ar-picker__toolbar" :style="BarStyle">
      <div class="ar-picker__cancel" @click="handleCancel"><span :style="Canceltyle">{{cancelText}}</span></div>
      <div class="ar-picker__title">{{title}}</div>
      <div class="ar-picker__confirm" @click="handleConfirm"><span :style="ConfirmStyle">{{confirmText}}</span></div>
  </div>
</template>

<script lang='ts'>
import { Component, Prop, Vue } from 'vue-property-decorator';

@Component
export default class  extends Vue {
  @Prop({default: '取消'}) private cancelText!: string;
  @Prop({default: '#5e6d82'}) private cancelColor!: string;
  @Prop({default: '确认'}) private confirmText!: string;
  @Prop({default: '#007bff'}) private confirmColor!: string;
  @Prop({default: '标题'}) private title!: string;
  @Prop({default: '44'}) private toolBarHeight!: number | string;

  get BarStyle(){
    return `${this.toolBarHeight ? this.toolBarHeight : 44}px`
  }

  get ConfirmStyle(){
    return {
      color: `${this.confirmColor ? this.confirmColor : '#007bff'}`
    }
  }

  get Canceltyle(){
    return {
      color: `${this.cancelColor ? this.cancelColor : '#007bff'}`
    }
  }

  private handleConfirm(){
    this.$emit('comfirm');
  }

  private handleCancel(){
    this.$emit('cancel');
  }
} 
</script>
private onCancel(){
    this.$emit('cancel');
 }

  private onConfirm(){
    this.$emit('confirm', {
      value: this.currentValue,
    });
 }
优化change触发

并不是改变offset时都要触发change,只有当现在的offset与上一个offset不一样或者正在移动结束时才触发。

 private moving: boolean = false;
 private transitionEndTrigger: any = null;

public handleTouchStart(event: TouchEvent){
 	// ...
    this.transitionEndTrigger = null;
}

public handleTouchMove(event: TouchEvent){
    // ...

    if(this.direction === 'vertical'){
      this.moving = true;
      preventDefault(event, true);
    }

    // ...
  }

private setIndex(index: number, emitChange?: boolean){
    index = this.adjust(index) || 0;
    const offset = -index * this.itemHeight;
    
    const trigger = () => {
      if(index !== this.currentIndex){
        this.currentIndex = index;
        
        if(emitChange){
          this.$emit('change', {
            columnIndex: this.columnIndex,
            currentIndex: index,
            item: this.columnList[index]
          });
        }
      }
    }
    
    if(this.moving && offset !== this.offset){
      this.transitionEndTrigger = trigger();
    }else{
      trigger();
    }
    
    this.offset = offset;
  }

停止column的惯性滑动

column的惯性滑动还没有停止时,你却按下了确定按钮,那么此时你应该停止惯性滑动,并将此刻停留的值告诉用户。

public stopMomentum(){
    this.moving = false;
    this.duration = 0;

    if(this.transitionEndTrigger){
      this.transitionEndTrigger();
      this.transitionEndTrigger = null;
    }
}

怎么在picker组件中调用columnstopMomentum方法?

private onConfirm(){
    this.$children.forEach((component: any) => {
      if(component.stopMomentum){
        component.stopMomentum();
      }
    })
    
    this.$emit('confirm', {
      value: this.currentValue,
    });
  }

尝试在惯性滑动的过程中点击确认按钮,stopMomentum确实被调用了3次,但是这时候的this.currentValue并不准确,可能会出现改变的那一列的后面列的值是空的。

  private onConfirm(){
    this.$children.forEach((component: any) => {
      if(component.stopMomentum){
        component.stopMomentum();
      }
    })

    // 级联切换列的时候,会出现空值
    if(this.dataType === 'cascade'){
      let currentValueArr = this.defaultValueArr;
      
      for(let i = 1, len = currentValueArr.length; i < len; i++){
        if(currentValueArr[i] === ''){
          currentValueArr[i] = this.formattedColumns[i][0][this.defaultKey];
        }
      }

      this.currentValue = currentValueArr.join(',');
    }
    

    this.$emit('confirm', {
      value: this.currentValue,
    });
  }

结尾

我又参考vant开发了一个picker组件,之前使用picker组件感觉在返回的值上面并不友好,于是在这次开发中用了自己的思路更改了传入的值的结构以及返回的值,感觉这样比较适合于自己开发。

感谢阅读~

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值