从0到1搭建一个组件库

最近我开启了一个新项目,基于echarts进行二次封装,希望能为Vue3项目量身打造一套高效、易用的图表组件库,取名为 v-echarts。

        目前雏形已经搭建完成,先把整个搭建过程做一个记录。后续再持续迭代、完善该图表组件库。

 v-echarts 文档地址:v-echarts-ui | Vue3 Charts Component Library (yoguoer.github.io)https://github.com/yoguoer/v-echarts-ui.git

v-echarts 仓库地址:https://github.com/yoguoer/v-echarts-ui.git

npm 地址:v-echarts-ui - npm (npmjs.com)

      效果如下:

有兴趣的小伙伴们可以加入我呀!~ 要是能给我点个小星星就更好啦!~~

笔者在搭建自己的组件库时,趁着项目当前足够空白,专门切出了一个 demo 分支,存下来当做模板以备不时之需。可以切换到 demo 分支,查看比较干净的项目代码。
地址如下:GitHub - yoguoer/v-echarts: v-echarts 是基于Element-plus和 echarts 进行二次封装的图表组件文档v-echarts 是基于Element-plus和 echarts 进行二次封装的图表组件文档. Contribute to yoguoer/v-echarts development by creating an account on GitHub.https://github.com/yoguoer/v-echarts.git

这里我以项目 v-echarts-Library 为例。首先,我们来看一下最终的目录结构,建立一个印象先:

一、主项目搭建

1、创建Vue3项目

npm init vite@latest

2、根据需要选择配置(这里选择了使用 TypeScript)

3、进入项目,安装依赖,并运行项目

  cd v-echarts-Library
  npm install
  npm run dev

此时的目录结构:

        可以根据需要,集成一些其他配置,比如 .prettierrc、.stylelintrc、.eslintrc 等等。

二、文档项目搭建

1、在根目录下创建一个 docs 文件夹,这里将会是我们的文档项目,然后进入该文件夹

2、安装vitepress,集成vitepress及完善其配置

pnpm add vitepress -D

3、根目录下创建目录 docs并在 其目录中创建 index.md,其文件就是文档首页内容,可根据需要书写,测试时可先随便写点:

# Hello Vitepress 

4、添加 scripts

"scripts": {
  "docs:dev": "vitepress dev docs",
  "docs:build": "vitepress build docs"
},

5、安装 postcss 防止vitePress 的样式去污染组件的样式

pnpm add postcss -D

6、在docs 目录下新建 postcss.config.mjs文件, 内容为:

import { postcssIsolateStyles } from 'vitepress'
​
export default {
    plugins: [
        postcssIsolateStyles({
            includeFiles: [/vp-doc\.css/] // defaults to /base\.css/
        })
    ]
}

7、在根目录中添加 pnpm-workspace.yaml 文件,键入如下内容:

packages: # pnpm-workspace.yaml 定义了 工作空间 的根目录,并能够使您从工作空间中包含 / 排除目录 。 默认情况下,包含所有子目录。
  - packages/* # 将 packages 目录下所有文件夹项目视为独立项目,能独立安装 node_modules
  - docs

启动vitepress文档,页面如下

配置 vitepress

1、首页配置(即:docs/index.md文件)如下修改:

---
title: v-echarts
titleTemplate: Vue3 Charts Component Library
description: Vue3 Charts Component Library
head:
  - - meta
    - name: description
      content: v-echarts is a Vue3 Charts Component Library
  - - meta
    - name: keywords
      content: v-echarts echarts Vue3 Charts Component Library

layout: home

# titleTemplate: 选项卡描述
editLink: true
lastUpdated: true
hero:
  name: v-echarts
  text: vue3图表组件
  tagline: Vue3 中基于echarts二次封装的图表组件文档
  image:
    src: /img/avator.jpg
    alt: v-echarts
  actions:
    - theme: brand
      text: 安装指南
      link: /zh-CN/guide/howToUse
    - theme: brand
      text: 组件预览
      link: /zh-CN/components/common-charts/组件总览
features:
  - icon: 🔨
    title: 实际项目
    details: 实际项目中碰到的疑点、难点,致力于更优的自我。
  - icon: 🧩
    title: 基础组件
    details: 基于Element-plus和echarts进行二次封装,使用组件 Demo 快速体验交互细节。
  - icon: ✈️
    title: Vue驱动。
    details: 享受 Vue3 + vite3 的开发体验,在 Markdown 中使用 Vue 组件,同时可以使用 Vue 来开发自定义主题。
---

启动vitepress文档,页面如下

2、在 docs 目录下新建一个 zh-CN 文件夹,用来存放文档,并在其中建一个 组件使用文档components 和一个 指南手册文档guide

3、在 components 目录中创建对应组件渲染.md文件

3、在 docs 目录下新建目录 .vitepress,在该目录中创建 config.ts 文件

        为了文件更加清晰,笔者将 nav、sidebar、footer 都分别成了独立的 .ts 文件,放在一个 config 文件夹中,然后再在 config.ts 文件中导入。

① 配置nav(定义顶部导航)。configs/nav.ts:

export default [
 {
     text: '指南',
     link: '/zh-CN/guide/howToUse'
 },
 { text: '基础图表', link: '/zh-CN/components/common-charts/test/readme' },
 {
     text: '博客',
     items: [
         { text: 'CSDN', link: 'https://blog.csdn.net/Vivien_CC' },
         { text: 'github', link: 'https://github.com/yoguoer' },
     ]
 }

② 配置页脚。configs/footer.ts:

export default {
message: 'v-echarts design by Vivien',
copyright: 'Copyright © 2024-present Vivien'
}

③ 配置侧边栏。configs/sidebar.ts:

export default {
'/zh-CN/guide': [
 {
   text:'环境准备',
   items:[
     {text:'如何使用',link:'/zh-CN/guide/howToUse'}
   ]
 },
 {
   text: '使用指南',
   items: [
     { text: '基础图表', link: '/zh-CN/guide/common-charts' },
   ]
 },
],
'/zh-CN/components/': [
 {
   text: '基础图表',
   items: [
     { text: '测试', link: '/zh-CN/components/common-charts/test/readme' },
     { text: '折线图', link: '/zh-CN/components/common-charts/Bar/readme' },
     { text: '条形图', link: '/zh-CN/components/common-charts/Line/readme' },
     { text: '饼图', link: '/zh-CN/components/common-charts/Pie/readme' },
     { text: '仪表盘', link: '/zh-CN/components/common-charts/Guage/readme' },
   ]
 },
]
}

④ 最终config.ts 文件如下

import { defineConfig } from 'vitepress'
import nav from './configs/nav'
import sidebar from './configs/sidebar'
import footer from './configs/footer'

export default defineConfig({
title: 'v-echarts',
description: 'v-echarts 是一个基于echarts和Vue3封装的图表组件库,主要用于快速构建数据可视化页面。',
lang: 'zh-CN',
base: `/v-echarts`,
head: [
 [
   'link',
   {
     rel: 'icon',
     href: '/img/vivien-logo.svg'
   }
 ]
],
lastUpdated: true,
themeConfig: {
 logo: '/img/vivien-logo.svg',
 // siteTitle: 'v-echarts',
 // outline: 3,
 socialLinks: [
   { icon: 'github', link: 'https://github.com/yoguoer' }
 ],
 nav,
 sidebar,
 footer,
 search: {
   provider: 'local'
 }
},
})

将Markdown文件中的代码示例转换为可显示在网页上的代码片段

我们使用一个插件来将 Markdown 文件中的代码示例转换为可显示在网页上的代码片段。

1、 在 docs文件夹下添加一个plugins文件夹,并创建 source-code.ts 文件,键入以下内容

import * as path from 'path'
import * as fsPromises from 'fs/promises'
import Prism from 'prismjs'
import loadLanguages from 'prismjs/components/index' // 语法高亮

loadLanguages(['markup', 'css', 'javascript'])
// 将源码使用 <pre> 标签包裹
const warp = code => `<pre v-pre><code>${code}</code></pre>`
/**
 * 实现原理是将一个字符串按照特定的模式(.*?source-code="(.*)")进行匹配,然后将匹配到的结果按照:::分割成数组
 * @param _ 
 * @returns 
 */
function sourceSplit(_: string) {
  const result = /.*?source-code="(.*)"/.exec(_)
  const originPath = (result && result[1]) ?? ''
  return originPath.split(':::')
}

// 项目包路径
const packagesPath = path.resolve(__dirname, '../../packages/')
/**
 * 用于将 Markdown 文件中的代码示例转换为可显示在网页上的代码片段。
 * 它首先会提取模块代码中的 source-code 属性,然后读取相应的文件,并将文件内容替换到属性中。
 * 这样,浏览器就可以直接解析模块代码中的代码片段了
 * docs:https://rollupjs.org/plugin-development/#transform
 * @param src string 源代码字符串
 * @param id  string | SourceMap;
 * @returns 
 */
const sourceCode = () => {
  return {

    //在每个传入模块请求时被调用  transform 钩子
    async transform(src: string, id: string) {
      const mdFile = '.md' // 文件类型
      // 文件非 markodwn 类型,返回
      if (!id.includes(mdFile)) return
      // 提取示例组件中source-code属性值
      const reg = /source-code="(.*)"/g
      // 示例组件中不存在source-code属性,返回
      if (!src.match(reg)) return
      //使用正则表达式从模块代码中提取 source-code 属性,并将其替换为实际的代码内容。
      const match = src.match(reg)?.map(_ => {
        // 获取示例组件项目名和文件地址,通过 “项目名:::组件路由地址” 进行分割
        const [packageName, compPath] = sourceSplit(_)
        // 获取项目类型,react 使用ant design 组件,使用 tsx 语法, Vue 使用
        const suffix = packageName.includes('ant') ? 'tsx' : 'vue'
        // 返回读取的文件内容
        return fsPromises.readFile(path.resolve(packagesPath, `${packageName}/demo/${compPath}.${suffix}`), 'utf-8')
      })
      const filesRes = await Promise.all(match)


      //正则表达式 /source-code="(.*)"/g 可能会匹配到多个匹配项,因此在替换时需要使用 i++ 来确保每次替换都是唯一的。
      let i = 0
      //读取模块代码中的所有文件,并将其内容合并到模块代码中

      return src.replace(reg, (str) => {

        // 获取示例组件项目名和文件地址,通过 “项目名:::组件路由地址” 进行分割
        const [packageName, compPath] = sourceSplit(str)
        // 获取示例组件地址
        const compPathStrArr = compPath.split('/')
        // iframe src 地址
        const iframeSrc = compPathStrArr[compPathStrArr.length - 1]
        // 正则匹配 @Vi-Echarts 路径别名替换为原路径
        const searchString = new RegExp(`@/Vi-Echarts/${packageName}`, 'g')
        const replaceString = `Vi-Echarts/${packageName}`
        const file = filesRes[i] ? (filesRes[i] as string).replace(searchString, replaceString) : null
        i++
        // 返回编码后的源码文件内容
        return `source-code="${encodeURIComponent(warp(Prism.highlight(file, Prism.languages.markup, 'markup')))}" raw-source="${encodeURIComponent(file)}" lib-type="${packageName}" iframe-src="${iframeSrc}"`
      })
    }
  }
}
export default sourceCode

2、 在 docs/vite.config.ts 中导入插件并使用

import sourceCode from './plugins/source-code'
import { defineConfig } from 'vite'
import path from 'path'
import { alias } from './plugins/alias'

export default defineConfig(async ({ command, mode }) => {
  const alia = await alias()
  return {
    server: {
      proxy: {
        '/assets': {
          target: 'http://localhost:8080',
          changeOrigin: true
        }
      }
    },
    plugins: [
      sourceCode()//将 Markdown 文件中的代码示例转换为可显示在网页上的代码片段
    ],
    resolve: {
      alias: [
        ...alia, // 统一项目包别名
        {
          find: '@/',
          replacement: path.join(__dirname, '/')
        }
      ]
    }
  }
})

封装渲染 Vue 的组件

1、在.vitepress文件夹下,新建一个components文件夹,用于存储我们的vue渲染模板

2、总体组件概况

代码如下:

<template>
  <el-row class="intro-card-container">
    <el-col :span="12" v-for="item in lists" :key="item">
      <el-card class="card-container" shadow="hover" @click="viewComponent(item.link)">
        <template #header>
          <div class="card-header">
            <span>{{ item.title }}</span>
            <el-tag v-if="item.tag" :type="item.tagType || 'primary'">
              {{ item.tag }}
            </el-tag>
          </div>
        </template>
        <div class="card-footer">
          <el-button
            :type="item.link ? 'primary' : 'info'"
            round
            :disabled="!item.link"
            @click="viewComponent(item.link)"
            >查看</el-button
          >
        </div>
      </el-card>
    </el-col>
  </el-row>
</template>

<script setup>
const props = defineProps({
  lists: {
    // 组件库名称
    type: Array,
    default: null,
  },
});

const viewComponent = (link) => {
  if (!link) {
    return false;
  }
  window && window.open(link);
};
</script>
<script>
export default {
  name: "IntroCard",
};
</script>

<style scoped lang="less">
.intro-card-container {
  display: flex;
  justify-content: flex-start;
  .card-container {
    margin: 5px;
  }
  .card-header {
    display: flex;
    justify-content: space-between;
  }
  .card-footer {
    display: flex;
    justify-content: flex-end;
  }
}
</style>

3、单个组件预览

代码如下:

<template>
  <div class="container" :class="{ 'full-screen-container': isFullScreen }">
    <div class="demo">
      <!--操作菜单-->
      <div class="menu">
        <i
          class="icon"
          v-for="icon in iconColorArr"
          :key="icon.color"
          :style="{ backgroundColor: icon.color }"
        >
          <el-icon
            v-if="icon.name === 'scale'"
            class="d-caret"
            @click="handleToggleFullScreen"
            ><DCaret
          /></el-icon>
        </i>
      </div>
       <!--demo展示组件,使用 iframe 内嵌组件-->
      <iframe
        class="elp-iframe"
        :class="{ 'full-screen-iframe': isFullScreen }"
        :src="`${baseUrl[props.libType]}#/${props.iframeSrc}`"
        :style="{ height: demoHeight }"
      />
    </div>
    <div class="options">
      <el-tooltip content="全屏预览" placement="bottom">
        <el-icon class="option-item" @click="handleToggleFullScreen"
          ><FullScreen
        /></el-icon>
      </el-tooltip>
      <el-tooltip content="复制代码" placement="bottom">
        <el-icon class="option-item" @click="copyCode"><CopyDocument /></el-icon>
      </el-tooltip>
      <el-tooltip content="查看源码" placement="bottom">
        <span class="option-item code-btn" @click="handleToggleCode">&lt;/&gt;</span>
      </el-tooltip>
    </div>
    <El-collapse-transition>
      <div class="source-code" v-if="isShowCode">
        <!--demo 源码内嵌显示-->
        <div class="decode" v-html="decoded" />
        <div class="hide-code-btn">
          <el-button type="info" link :icon="CaretTop" @click="handleToggleCode"
            >隐藏源代码</el-button
          >
        </div>
      </div>
    </El-collapse-transition>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
import {
  ElIcon,
  ElTooltip,
  ElCollapseTransition,
  ElButton,
  ElMessage,
} from "element-plus";
import { FullScreen, DCaret, CaretTop, CopyDocument } from "@element-plus/icons-vue";
import { useClipboard } from "@vueuse/core";
import "prismjs/themes/prism-tomorrow.css";

const props = defineProps({
  libType: { // 组件库名称
    type: String,
    default: "element-plus",
  },
  iframeSrc: {
    type: String,
    default: "",
  },
  demoHeight: {
    type: String,
    default: "320px",
  },
  sourceCode: {
    type: String,
    default: "",
  },
  rawSource: {
    type: String,
    default: "",
  },
});

const decoded = computed(() => {
  return decodeURIComponent(props.sourceCode);
});
// 组件库 demo 构建地址
const baseUrl = {
  "common-charts": import.meta.env.VITE_COMMON_DEV_BASE,
};

const iconColorArr = [
  { name: "", color: "#646cff" },
  { name: "", color: "#9499ff" },
  { name: "scale", color: "#bcc0ff" },
];

const isFullScreen = ref(false);
const isShowCode = ref(false);
//全屏预览
const handleToggleFullScreen = () => (isFullScreen.value = !isFullScreen.value);
//查看源码
const handleToggleCode = () => (isShowCode.value = !isShowCode.value);
//复制源码
const { copy, isSupported } = useClipboard({
  source: decodeURIComponent(props.rawSource),
  read: false,
});

const copyCode = async () => {
  if (!isSupported) {
    ElMessage.error("不支持复制");
  }
  try {
    await copy();
    ElMessage.success("复制成功");
  } catch (e) {
    ElMessage.error(e.message);
  }
};

onMounted(() => {
  // 监听键盘 esc键,退出全屏
  document.addEventListener("keyup", (e) => {
    if (e.key === "Escape" || e.key === "Esc") isFullScreen.value = false;
  });
});
</script>
<script>
export default {
  name: "vEchartsDemo",
};
</script>

<style scoped lang="less">
@menu-height: 32px;

.container {
  border: 1px solid #dcdfe6b2;
  border-radius: var(--ve-border-radius-base);
}
.full-screen-container {
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
  background-color: #fff;
  z-index: 10000;
}
.demo {
  .menu {
    border-radius: var(--ve-border-radius-base) var(--ve-border-radius-base) 0 0;
    height: @menu-height;
    line-height: @menu-height;
    background-color: var(--ve-custom-block-details-bg);
    padding: 0 16px;
    .icon {
      position: relative;
      display: inline-block;
      width: 15px;
      height: 15px;
      margin-right: 5px;
      &:hover {
        .d-caret {
          display: block;
          cursor: pointer;
        }
      }
      .d-caret {
        display: none;
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%) rotate(-45deg);
        color: var(--ve-c-text-2);
        font-size: 13px;
      }
    }
  }
  .elp-iframe {
    width: 100%;
    padding: 15px;
    border: 0;
  }
  .full-screen-iframe {
    width: 100vw;
    height: calc(100vh - @menu-height) !important;
  }
}
.options {
  border-top: 1px solid #dcdfe6b2;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  .option-item {
    margin-right: 8px;
    cursor: pointer;
    color: var(--ve-c-text-2);
    font-size: 12px;
    &:hover {
      color: var(--ve-c-text-1);
    }
  }
}
.source-code {
  background-color: #f6f8fa;
  position: relative;
  border-top: 1px solid #dcdfe6b2;
  .decode {
    padding: 0 16px;
  }
  .hide-code-btn {
    border-top: 1px solid #dcdfe6b2;
    border-radius: 0 0 var(--ve-border-radius-base) var(--ve-border-radius-base);
    position: sticky;
    bottom: 0;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 14px;
    background-color: var(--vp-code-block-bg);
    z-index: 10;
    .icon {
      margin-right: 8px;
    }
    &::hover{
      color: black;
    }
  }
}
</style>

4、在index.ts中导出

import vEchartsDemo from './vEcharts-demo.vue';
import IntroCard from './intro-card.vue';
export const globals = [vEchartsDemo, IntroCard];

三、组件项目搭建

1、首先需要创建一个 packages 目录,用来存放组件,这里面是可以独立运行的项目

笔者开发的是图表组件库,分为两个项目:基础图表库 common-charts 和动态图表库 dynamic-charts

- packages
---- common-charts // 基础图表
---- dynamic-charts // 动态图表

packages 中的独立项目,搭建过程和我们的普通项目其实是一致的,也可以参考前面的住项目搭建步骤,这里只记录额外需要的部分。

2、每个图表库中都有一个用于全局注册组件的文件 withInstall.ts 和用于遍历并注册组件的 install.ts, 以及最外层的用于导出所有组件的 index.ts

为了使目录结构更清晰,将 withInstall.ts 和 install.ts 放在一个utils文件夹里

① common-charts/utils/withInstall.ts

import { App, Plugin } from "vue"

// 定义一个类型,用于表示带有 install 方法的组件
type SFCWithInstall<T> = T & Plugin

// 定义一个函数,用于给组件添加 install 方法
export const withInstall = <T, E extends Record<string, any>>(main: T, extra?: E) => {
  // 给 main 添加 install 方法
  ;(main as SFCWithInstall<T>).install = (app: App) => {
    // 遍历 main 和 extra 中的组件,将它们注册到 app 中
    for (const comp of [main, ...Object.values(extra ?? {})]) {
      app.component(comp.name, comp)
    }
  }
  // 如果有 extra,则将 extra 中的组件添加到 main 中
  if (extra) {
    for (const [compName, comp] of Object.entries(extra)) {
      ;(main as Record<string, any>)[compName] = comp
    }
  }
  // 将 T 断言为具体的类型 T & plugin & Record<string, any>
  return main as SFCWithInstall<T> & E
}

② common-charts/utils/install.ts

import vEchartsTest from '../components/test'  // 导入已经开发好的组件

// 存储组件列表
const components = [
  vEchartsTest,
]

// 插件注册:在 Vue 项目的入口文件中,通过 ( app.use(插件) ) 进行注册
const installComponents = (app: any) => {
  components.forEach((comp: any) => { // 遍历并全局注册组件
    // app.component(comp.name as string, comp)
    app.use(comp)
  })
}

// 导出组件
export const installer = (app: any, router?: any) => {
  // 导出的对象必须具有 install,才能被 Vue.use() 方法安装
  installComponents(app)
}

③ common-charts/index.ts

export * from './components'
export { installer as commonChartsInstall } from './install'

目录结构如下:

四、组件开发

接下来,将以 common-charts 中的 test 作为例子来介绍整个过程

1、packages 中的每个文件夹,都存放着组件开发目录 components 和组件测试目录 demo

-- packages
|--- common-charts 
	|--- components //组件开发目录
	|--- demo //组件测试目录
|---  dynamic-charts 
	|--- components
	|--- demo

2、每个单独的组件开发目录,最外层的 index.ts 用于整合所有组件,并对外暴露出去(代码如第 5 点所示)

3、components 中的每个组件,都应该归类于单独的目录下,包含其组件源码目录 src,和 index.ts,以便于外部引用

这里以组件 vEchartsTest 为例

4、在 components 和 demo 中分别新建一个 test 文件夹

  • components 文件夹中:

① 最外层的 components/index.ts 用于整合并导出组件

export * from './test'
export * from './Bar'
export * from './Line'
export * from './Pie'

① components/test/src/index.vue 用于开发我们的组件,在这里就先随便写点东西好了(注意,组件必须声明 name,这个 name 就是组件的标签)

<template>
    <span>哈哈哈哈哈哈</span><br/>
    <el-input placeholder="hahaha" style="width: 300px;"/>
</template>

<script setup lang="ts" name="Test">
</script>
<script>
export default {
  name: "Test",
};
</script>

② components/test/index.ts 用于导出 test 组件

import Test from './src/index.vue' // 引入组件
import { withInstall } from '../../withInstall' // 引入 withInstall 函数

// 使用 withInstall 注册组件并导出组件
const vEchartsTest = withInstall(Test)
export default vEchartsTest
  • demo 文件夹中:

demo /test/test-demo.vue 用于使用 test 组件,提供使用示例

注意,这里的代码将会成为文档中 ”查看源码“ 功能中见到的代码和”复制代码“功能中得到的代码

<template>
  <vEchartsTest />
</template>

<script setup lang="ts">
import vEchartsTest from '../../components/test'
</script>

五、书写文档

在这里,我们使用前面已经封装好的 Vue 渲染组件来引入组件使用示例即可

# 哈哈
测试一下测试一下
## 示例

### 常规使用

<vEcharts-demo
    demo-height="300px"
    source-code="common-charts:::test/test-demo"
/>

效果如下:

六、配置路由

写完文档后,就可以在 config.ts 中配置顶部导航栏 nav 和侧边栏 sidebar 了。

这里需要根据自己的文件所在路径去配置!

笔者是创建了一个 zh-CN 文件夹,里面包含指南文档 guide 和组件文档 components(里面的目录结构又和 packages 文件夹里面的一一对应)

nav:[
	{ text: '基础图表', link: '/zh-CN/components/common-charts/test/readme' },
]
sidebar:[
	  '/zh-CN/components/': [
    {
      text: '基础图表',
      items: [
        { text: '测试', link: '/zh-CN/components/common-charts/test/readme' },
      ]
    },
  ]
]

完成

经过这些操作步骤,我们的组件库雏形就已经搭建完成啦!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值