华为鸿蒙手写ECharts

   ECharts作为前端强大的图表、K线、地图等封装库可以说无比风骚。但用户和产品的需求永远是一个库满足不了的,除非产品和设计的基础是在图表库基础上进行。我们前端移动端作为产品的排面就应该让其独具特色,别具一格。所以自定义从产品设计技术岗位亿万用户不同需求...出发,"自定义极其重要"。

一、自定义

      今天看了看ArkTs对绘制API的封装,可谓是和JS一模一样。大家根据官方API粗略浏览。至于自定义从零基础到高手,我相信只要花费一下午时间,阅读、练习、理解 前端都是手写ECharts ?就足够了,学不会我手把手教你。

二、需求分析

最近项目中产品要求扇形展示票务的所占比例。产品不知道哪里拿了截图,截图如下,说实话有丑到我。于是花了一下午时间实现了一个比较好看的效果。支持了【手势旋转,指示器跟随滑动,文字分段,点击选中显示指示器等效果】

b80d72dac9230bcb1ca621578c1f010e.jpeg

  1. 我让产品和设计加上手势转动,他们觉得如果可以,更好。

  2. 文字过长问题,我说可以限制显示区域,分行显示,他们觉得能实现更好。

50be0b0578fadbdf3e1b68b0b1e5fa79.jpeg

  1. 票务类型过多,避免不了重叠问题,即使分行显示,我说要不手势触摸相应区域让其对应文字出现,其他隐藏,他们觉得这也太棒了。

5561c7d907e71cff766ebf81454980c6.jpeg

触摸到对应区域,显示当前区域文字指示器和对称指示器。而不是全部显示避免重叠。

d20a333a2522601295c95cb78e7e41de.jpeg

大概效果如下:

fdd18fb4df6546ab4950b8e600a3a000.jpeg

在技术角度来看,我们需要的是基本的绘制API,和手势旋转相关的API。使用的语言是ArkTS。
官方画布基础API地址

https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V2/js-components-basic-chart-0000001428061800-V2

官方手势基础API地址

https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V2/4_2_u57fa_u7840_u624b_u52bf-0000001427902444-V2

三、编写代码

1、项目创建

创建一个ArkTS项目,新建页面CanvasPage.ets并根据官方画布基础API进行简单的扇形区域绘制。觉得难的先看完前端都是手写ECharts ?基础绘制。

@Entry
@Component
struct CanvasDemoPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)


  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
        .onReady(() => {
          let width = this.context.width
          let height = this.context.height


          //将坐标圆心变换到屏幕中心即圆心,方便操作
          this.context.translate(width / 2, height / 2)
          //绘制开始,每次绘制不同内容,都需要开始和之前的绘制不在交织。
          this.context.beginPath()
          //开始绘制地方
          this.context.moveTo(0, 0)
          //在屏幕中心(0,0)绘制一个半径为100像素的扇形区域,扇形角度为45度。可以看到水平X轴是扇形弧度开始地方。顺时针绘制。
          this.context.arc(0, 0, 100, 0, Math.PI * 0.25)
          //关闭路径,这样弧度结尾会自动连接起时的圆心区域
          this.context.closePath()
          
          this.context.fillStyle = 'rgb(111,212,124)'
          this.context.fill()
        })
    }
    .height('100%')
  }
}

ba0454a88da73948a376debba8fb1987.jpeg

2、数据绘制

创建数据类,初始化一个数据集合

class CirclePieChartMaxBean {
  valueData: number//票数
  title: string//指示器标题
  sub: string//指示器副标题
  color: ResourceColor | string//扇形区域颜色


  constructor(valueData: number, title: string, sub: string, color: ResourceColor | string) {
    this.valueData = valueData
    this.title = title
    this.sub = sub
    this.color = color
  }
}
let dataList: Array<CirclePieChartMaxBean> = [
  new CirclePieChartMaxBean(
    30,
    "Compose",
    "60%",
    'rgba(53,158,255,1.00)'
  ),
  new CirclePieChartMaxBean(
    30,
    "Flutter",
    "30%",
    'rgba(67, 223, 210, 1.00)'
  ),
  new CirclePieChartMaxBean(
    10,
    "ArkTS",
    "5%",
    'rgba(255, 212, 81, 1.00)'
  ),
  new CirclePieChartMaxBean(
    20,
    "Xml",
    "3%",
    'rgba(155, 115, 226, 1.00)'
  ),
  new CirclePieChartMaxBean(
    10,
    "Vue",
    "10%",
    'rgba(239, 184, 200, 1.00)'
  )
]

首先自定义绘制的是用户需求的数据,而数据并非角度,用户的数据和角度如何进行映射是关键。我们知道一个扇形统计图一周的角度是360度,即对应数据的总和。而每一个语言数据所占的比例不难求出(某语言类型数据/所有语言总和)。从而可以求出每一份语言所占的角度公式 = 某语言数/语言总和。

我们不难求出每个编程语言所对应的角度。

import { dataList } from '../data/model/CirclePieChartMaxBean'




@Entry
@Component
struct CanvasDemoPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)


  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
        .onReady(() => {
          let width = this.context.width
          let height = this.context.height
          this.context.translate(width / 2, height / 2)
          this.context.beginPath()
          this.context.moveTo(0, 0)
          this.context.arc(0, 0, 100, 0, Math.PI * 0.25)
          this.context.closePath()
          this.context.fillStyle = 'rgb(111,212,124)'
          this.context.fill()
          
          //求和
          let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
          for (let index = 0; index < dataList.length; index++) {
            const angleData = dataList[index];
            let sweepAngle = angleData.valueData / sum * 360
            console.log("sweepAngle",sweepAngle.toString())
          }
        })
    }
    .height('100%')
  }
}

打印结果

05-17 16:41:59.335    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 108
05-17 16:41:59.336    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 108
05-17 16:41:59.336    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 36
05-17 16:41:59.336    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 72
05-17 16:41:59.336    A03d00/JSAPP      com.examp...kts_unit  I     sweepAngle 36
已经知道角度如何绘制?我们需要明确,每次绘制都必须在上一个绘制的弧度结尾接着绘制。所以需要每次绘制完记录一下绘制结尾的弧度。下次绘制在这个弧度基础上绘制。
@Entry
@Component
struct CanvasDemoPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)


  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
        .onReady(() => {
          let radius = 80
          //记录上一个绘制结束的角度。
          let routeAngle = 0


          let width = this.context.width
          let height = this.context.height
          this.context.translate(width / 2, height / 2)


          let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
          for (let index = 0; index < dataList.length; index++) {
            const angleData = dataList[index];
            //每个扇形区域
            let sweepAngle = angleData.valueData / sum * 360
            console.log("sweepAngle",sweepAngle.toString())


            //绘制扇形区域开始
            this.context.save()
            //我习惯垂直方向向上作为弧度绘制开始,而不喜欢水平,这个应该由产品决定,而我这里选择了-90到了12点位置作为绘制起始
            //为了让扇形每一份都朝着自己弧度中心向外traslate,所以我这里让会让坐标系横轴X转到每个扇形弧度中间,接着直接translate(间隙距离,0)就可以达到间隙效果。
            let rotateAngle = (routeAngle + sweepAngle / 2 - 90)
            this.context.rotate(rotateAngle / 180 * Math.PI)
            //这个让绘制坐标系每次偏离5像素,最终会让每个扇形之间有间隙。
            this.context.translate(5, 0)
            this.context.beginPath()
            this.context.moveTo(0, 0)
            //记得测试X方向如下图二,所以-sweepAngle/2即12点钟方向。测试坐标系0角度即x轴方向。这里好好理解一下,理解不了去看之前的文章。
            this.context.arc(0, 0, radius, Math.PI * (-sweepAngle / 2 / 180), Math.PI * (sweepAngle / 2 / 180))
            this.context.closePath()
            this.context.fillStyle = angleData.color.toString()
            this.context.shadowBlur = 10
            this.context.shadowOffsetX = 1
            this.context.shadowColor = 'rgb(0,0,0)'
            this.context.fill()
            this.context.restore()


            //每绘制完一个区域,都需要在之前基础上加上次角度,便于后面在此之上接着绘制
            routeAngle += sweepAngle
          }


        })
    }
    .height('100%')
  }
}

790bcb47b7c1795a973cfd0dba5e527c.jpeg弧度绘制完成。

3、指示线绘制

在上面旋转到弧度中间的基础上我们利用勾股定理,进行计算线的位置。不难求出A点、B点、C点位置。这里需要知道文字长宽策略API为:let textMeasure = this.context.measureText(angleData.title),开发者可以通过textMeasure.height和textMeasure.width获取文字的长度来得到C点的坐标,用于绘制文字线。

293340cc7acff235150dd0f0c9a5b37a.jpeg

private drawTextLine(radius: number, halfAngle: number, angleData: CirclePieChartMaxBean) {
  //上图A点
  const pointToCircle = 20
  const titleLineToPDistance = 20
  const mTitleMarginStar = 10
  let centerX = (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
  let centerY = -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
  let lineTwoPointX = (radius + pointToCircle + titleLineToPDistance) * Math.sin(
    this.degreesToRadians(
      halfAngle + 90
    )
  )
  let lineTwoPointY = -(radius + pointToCircle + titleLineToPDistance) * Math.cos(
    this.degreesToRadians(
      halfAngle + 90
    )
  )
  //计算文字长度
  let textTitleMeasure = this.context.measureText(angleData.title)
  let textSubMeasure = this.context.measureText(angleData.sub)


  this.context.beginPath()
  this.context.moveTo(centerX, centerY)
  this.context.lineTo(lineTwoPointX, lineTwoPointY)
  this.context.lineTo(
    lineTwoPointX + textTitleMeasure.width + mTitleMarginStar,
    lineTwoPointY
  )


  let textDrawStartX = lineTwoPointX + mTitleMarginStar
  let textSubStartX = lineTwoPointX + textTitleMeasure.width + mTitleMarginStar - textSubMeasure.width


  this.context.lineWidth = 1
  this.context.shadowBlur = 4
  this.context.shadowOffsetY = 0.5
  this.context.shadowColor = 'rgb(0,0,0)'
  this.context.strokeStyle = 'rgba(53,158,255,1.00)'
  this.context.stroke()
}

88f4d73bfb00cd4ad5d636aa8783d2cb.jpeg

8bdaa2a503904ee6de86390917a8dd09.jpeg

不难发现在第一象限和第二象限是没啥大问题,但是三四象限貌似反向折叠了。所以我们需要不同象限进行绘制,如果花费5分钟草稿进行分析,不难发现第一第二象限绘制基本是一致的,三四象限绘制是一致的。所以我们需要根据象限来不同的计算绘制文字线。大家花5分钟分析一下三四象限。

需要明白,产品效果是可以跟随手势任意旋转的,每一个扇形在用户手上可能被旋转个百八十圈。那每个扇形都可能被旋转到任意角度吧?但是需要清楚,再牛逼还是在360度的可视化范围内。而正余弦函数大家应该知道是周期性函数,360度为一个周期,后面循环往复。所以任意旋转角度都能够被换算到360度以内,也就可以根据角度计算出所在象限。

//进行象限获取
determineQuadrant(angleDegrees: number): number {
  // 将角度标准化到0-360度之间
  let standardizedAngle = angleDegrees % 360;


  // 将负角度转换为对应的正角度
  if (standardizedAngle < 0) {
    standardizedAngle += 360;
  }


  // 判断角度所在的象限
  if (standardizedAngle >= 0 && standardizedAngle < 90) {
    return 1;
  } else if (standardizedAngle >= 90 && standardizedAngle < 180) {
    return 2;
  } else if (standardizedAngle >= 180 && standardizedAngle < 270) {
    return 3;
  } else {
    return 4;
  }
}

所以不难绘制出所有的指示线吧,文字绘制加上看看效果,如果难就画图勾股定理安排。

private drawTextLine(radius: number, halfAngle: number, angleData: CirclePieChartMaxBean) {
  const pointToCircle = 20
  const titleLineToPDistance = 20
  const mTitleMarginStar = 10
  this.context.font = "44px"
  let centerX = (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
  let centerY = -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
  if (this.determineQuadrant(halfAngle + 90.0) == 1 || this.determineQuadrant(halfAngle + 90.0) == 2) {
    let centerX =
      (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
    let centerY =
      -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))
    let lineTwoPointX =
      (radius + pointToCircle + titleLineToPDistance) * Math.sin(
        this.degreesToRadians(
          halfAngle + 90
        )
      )
    let lineTwoPointY =
      -(radius + pointToCircle + titleLineToPDistance) * Math.cos(
        this.degreesToRadians(
          halfAngle + 90
        )
      )
    let textTitleMeasure = this.context.measureText(angleData.title)
    let textSubMeasure = this.context.measureText(angleData.sub)


    this.context.beginPath()
    this.context.moveTo(centerX, centerY)
    this.context.lineTo(lineTwoPointX, lineTwoPointY)
    this.context.lineTo(
      lineTwoPointX + textTitleMeasure.width + mTitleMarginStar,
      lineTwoPointY
    )


    let textDrawStartX = lineTwoPointX + mTitleMarginStar
    let textSubStartX = lineTwoPointX + textTitleMeasure.width + mTitleMarginStar - textSubMeasure.width


    this.context.lineWidth = 1
    this.context.shadowBlur = 4
    this.context.shadowOffsetY = 0.5
    this.context.shadowColor = 'rgb(0,0,0)'
    this.context.strokeStyle = 'rgba(53,158,255,1.00)'
    this.context.stroke()


    //绘制线头圆点
    this.context.beginPath()
    this.context.arc(centerX, centerY, 5, 0, Math.PI * 2)
    this.context.fillStyle = 'rgba(53,158,255,1.00)'
    this.context.fill()


    this.context.beginPath()
    this.context.arc(centerX, centerY, 3, 0, Math.PI * 2)
    this.context.fillStyle = 'rgba(255,255,255,1.00)'
    this.context.fill()


    //绘制文字
    this.context.shadowBlur = 2
    this.context.shadowOffsetY = 0.5
    this.context.fillStyle = 'rgba(53,158,255,1.00)'
    this.context.fillText(angleData.sub, textSubStartX, lineTwoPointY - textSubMeasure.height / 2)
    this.context.fillStyle = 'rgb(0,0,0)'
    this.context.fillText(angleData.title, textDrawStartX, lineTwoPointY + textTitleMeasure.height)




  } else if (this.determineQuadrant(halfAngle + 90.0) == 3 || this.determineQuadrant(halfAngle + 90.0) == 4) {


    let centerX =
      (radius + pointToCircle) * Math.sin(this.degreesToRadians(halfAngle + 90))
    let centerY =
      -(radius + pointToCircle) * Math.cos(this.degreesToRadians(halfAngle + 90))




    let lineTwoPointX =
      (radius + pointToCircle + titleLineToPDistance) * Math.sin(
        this.degreesToRadians(
          halfAngle + 90
        )
      )
    let lineTwoPointY =
      -(radius + pointToCircle + titleLineToPDistance) * Math.cos(
        this.degreesToRadians(
          halfAngle + 90
        )
      )
    let textTitleMeasure = this.context.measureText(angleData.title)
    let textSubMeasure = this.context.measureText(angleData.sub)


    this.context.beginPath()


    this.context.moveTo(lineTwoPointX - textTitleMeasure.width - mTitleMarginStar, lineTwoPointY)
    this.context.lineTo(lineTwoPointX, lineTwoPointY)
    this.context.lineTo(
      centerX, centerY
    )
    this.context.stroke()
    //绘制文字
    let textDrawStartX = lineTwoPointX - textTitleMeasure.width - mTitleMarginStar
    let textSubStartX = textDrawStartX


    this.context.lineWidth = 1
    this.context.shadowBlur = 4
    this.context.shadowOffsetY = 0.5
    this.context.shadowColor = 'rgb(0,0,0)'
    this.context.strokeStyle = 'rgba(53,158,255,1.00)'
    this.context.stroke()


    //绘制线头圆点
    this.context.beginPath()
    this.context.arc(centerX, centerY, 5, 0, Math.PI * 2)
    this.context.fillStyle = 'rgba(53,158,255,1.00)'
    this.context.fill()


    this.context.beginPath()
    this.context.arc(centerX, centerY, 3, 0, Math.PI * 2)
    this.context.fillStyle = 'rgba(255,255,255,1.00)'
    this.context.fill()


    //绘制文字
    this.context.shadowBlur = 2
    this.context.shadowOffsetY = 0.5
    this.context.fillStyle = 'rgba(53,158,255,1.00)'
    this.context.fillText(angleData.sub, textSubStartX, lineTwoPointY - textSubMeasure.height / 2)
    this.context.fillStyle = 'rgb(0,0,0)'
    this.context.fillText(angleData.title, textDrawStartX, lineTwoPointY + textTitleMeasure.height)
  }
}

效果如下:

d65023cf07d3d060e9508c7971c10d3f.jpeg

4、增加手势

好用的统计图表必须不仅仅是要好看,还要好的交互。而扇形统计图更适合增加手势旋转。官方手势基础API地址

手势API能拿到用户旋转的角度,在此案例中只需要将当前旋转的角度交给context.rotate即可。

手势使用也很简单:下图所示,rotation就是用户手势旋转角度。

a114f5c2988fc6836a312af14f966bf4.jpeg

手势角度作为绘制弧度的其实角度,所以在进行旋转之前角度需要加上手势角度rotation。我们最后进行封装为draw()方法,在手势每次触发旋转回调函数中进行调用重新绘制。

@Entry
@Component
struct CanvasDemoPage {
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  @State rotation: number = 0
  @State rotateValue: number = 0


  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
          // 双指旋转触发该手势事件
        .gesture(
          RotationGesture()
            .onActionStart((event: GestureEvent) => {
              console.info('Rotation start')
            })
            .onActionUpdate((event: GestureEvent) => {
              this.rotation = this.rotateValue + event.angle
              this.draw()
            })
            .onActionEnd(() => {
              this.rotateValue = this.rotation
              console.info('Rotation end')
            })
        )
        .onReady(() => {
          this.draw()
        })
    }
    .height('100%')
  }


  private draw() {
    let radius = 80
    //记录上一个绘制结束的角度。
    let routeAngle = 0
    let defaultAngle = -90 + this.rotation


    let width = this.context.width
    let height = this.context.height
    this.context.translate(width / 2, height / 2)


    let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0)
    for (let index = 0; index < dataList.length; index++) {
      const angleData = dataList[index]
      //每个扇形区域
      let sweepAngle = angleData.valueData / sum * 360
      let halfAngle = defaultAngle + sweepAngle / 2
      console.log("sweepAngle", sweepAngle.toString())


      //绘制扇形区域开始
      this.drawArc(routeAngle, sweepAngle, radius, angleData)
      //开始绘制指示线
      this.drawTextLine(radius, halfAngle, angleData)


      defaultAngle += sweepAngle
      routeAngle += sweepAngle
    }
  }
}

94a3e512cb88fb6a178989402cf34d5b.jpeg

效果如上,运行之后,手势触发旋转,会在右下角重新绘制。其实并非在右下角,应该会在对角直线上一直往前绘制,在js和arkTs中绘制需要明白,每次调用绘制函数,是需要清空画布重新绘制,避免保留上一次的绘制干扰当前绘制。

//清空画布,避免重绘,否则每次drawSector都会绘制一次。最后重叠交错
this.context.clearRect(0, 0, width, height)

并在内部绘制一个白色圆圈挡住中间部分,形成圆环。

代码:

private draw() {


  this.context.save()
  //设置字体大小
  this.context.font = "30px"
  let radius = 80
  let routeAngle = 0
  let startAngle = 0
  let defaultAngle = -90 + this.rotation
  let sum: number = dataList.reduce((accumulator, currentValue) => accumulator + currentValue.valueData, 0);
  let width = this.context.width
  let height = this.context.height
  //清空画布,避免重绘,否则每次drawSector都会绘制一次。最后重叠交错
  this.context.clearRect(0, 0, width, height)
  this.context.save()
  //将坐标圆心变换到屏幕中心即圆心
  this.context.translate(width / 2, height / 2)
  for (let index = 0; index < dataList.length; index++) {
    const angleData = dataList[index]
    //每个扇形区域
    let sweepAngle = angleData.valueData / sum * 360
    let halfAngle = defaultAngle + sweepAngle / 2
    console.log("sweepAngle", sweepAngle.toString())


    //绘制扇形区域开始
    this.drawArc(routeAngle, sweepAngle, radius, angleData)
    //开始绘制指示线
    this.drawTextLine(radius, halfAngle, angleData)


    defaultAngle += sweepAngle
    routeAngle += sweepAngle
  }
  this.drawTextCenter(radius)
  this.context.restore()
}


//绘制中心白圆和文字。
private drawTextCenter(radius: number) {
  this.context.arc(0, 0, radius - 25, 0, Math.PI * 2)
  this.context.fillStyle = Color.White
  this.context.shadowBlur = 0
  this.context.shadowOffsetY = 0
  this.context.fill()


  this.context.fillStyle = 'rgba(53,158,255,1.00)'
  this.context.shadowBlur = 6
  this.context.shadowOffsetY = 3
  this.context.shadowColor = 'rgba(53,158,255,1.00)'
  let centerTextMeasure = this.context.measureText("APP")
  this.context.font = "40px"
  this.context.fillText("APP", -centerTextMeasure.width / 2, centerTextMeasure.height / 2)
}

bdf59709304d880cbad681d7c6d04f43.jpeg

至于文字分段显示、展示手势按下部分🈯️示线等功能也不难,大家可以自行实现,由于时间问题,这篇文章到此结束。有疑问或者错误可以评论区指出。

四、总结

看完官方语言基础和状态装饰器文档,试了这篇自定义内容,还是能够直接上手的。自定义对于UI的排面来说是极其重要的,如下是最近要出小册案例的冰山一角,小册内容极其丰富,里面涵盖了View,Compose,Js以及ArkTs大家可以关注点赞,希望对大家所有帮助。

d1d69cc48fcb11f13b4df24a51ddf848.jpeg

c871cc353b9d96db5e9ebd4116509d48.jpeg

f1f8afcf831aeb86da7f9d095a87885c.jpeg

ffada5159fbe78325e1b33c06dae5e5e.jpeg

487c4ad7e2125c30e87d9be3007b6a4e.jpeg1752938b7497d9829365c13095af9a5c.jpegd073fc3a299b79c2af83944c681dfabe.jpegca7a938cbe02959541b0d3e1ff428b3b.jpeg0b6f05494fa1d3f23f90a38d1845d12f.jpeg

84e09bc82100501a6e7c494b143bdb2b.jpeg

作者:路很长OoO
链接:https://juejin.cn/post/7370008254719950887

关注我获取更多知识或者投稿

336f34165583a7260ef717c2746e5861.jpeg

75afca90eb49555c27b7bfb96ff53c78.jpeg

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值