别人休息我努力,悄悄写个 cli 工具,必须提升效率,skr~

版权声明:本人文章仅在掘金平台发布,请勿抄袭搬运,转载请注明作者及原文链接 🦉

作者:北岛贰

https://juejin.cn/post/7256702654579310652

阅读提示:网页版带有主题和代码高亮,阅读体验更佳 🍉

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

如果你不想看文章,可以直接阅读源码:xwg-cli[1],如果有收获请点个 star。

开门见山,先看效果:

xwg \--help

dd47fb6ae248d7523be5a6b045f2e873.jpeg
image.png

xwg \--version

2c6c22f00619540ffd3e3e172f1af71a.jpeg
image.png

xwg create demo

4bd11f6b855193967c6e4eb05deaedd2.jpeg
image.png
2c9648c29210b2d99ad6d2cac0470a14.jpeg
image.png
468b544427266bd86d2e3a80ebd79eae.jpeg
image.png

xwg create \--help

3f6b33e0611e58319c90ffa6228b5e76.jpeg
image.png

再来看看模板的质量如何:

每份模板都是我精心准备的,具备较为完善的工程化能力,包括 eslintcommitlintgit czCHANGELOG 等。但是也没有过多地干涉你的使用,并不是完全强制的。例如,虽然我配置了 eslint,但是没有强制规则,你可以自己增减。

koa:

搭建了完整的 koa 开发结构,内置了一个 User 模块,开箱即用。像 sequelizelog4js 等功能都是完备可用的,是不是非常用心,有木有!!

dc8ad8262a07668169d487f0889785c5.jpeg
image.png

uniapp + vue2 + uview:

uniapp 这份 vue2 版本的模板,基于我自己的商业项目进行改造,像请求模块都是内置好的。另外,这个模板还加入了 uview-ui,省去了你寻找 UI 添加组件库的步骤,关键是添加 UI 组件库真的有不少坑,用了这个模板直接快人一步,少踩起码 10 个坑。

88793728061412a8cc3115a42fc43e90.jpeg
image.png

使用简介

在介绍开发代码之前,想必你也想先尝试下效果。

首先,确保你的 node 版本是 14.18+ 或者 16+

执行 yarn add xwg-cli \-g,重新打开终端执行 xwg \--version 看看是否成功安装。目前支持的命令有:

xwg \--help

xwg \--version

xwg create <项目名>

其实 --help--version 是自带的(后面会详细讲),这两个命令更严谨的说法是参数,参数一般就是 -- 开头,只有 create 才算是命令,目前 cli 仅完成了创建项目的能力,其他的能力正在思考建设中。

如果你不想下载,也可以执行 npx xwg-cli create demo 尝试创建项目。想必你又有疑问,为什么我的 cli 叫作 xwg-cli,但是我执行的时候是 xwg xxx,为什么不是 xwg-cli xxx?这些问题都将在我们接下来的文章中解答。

接下来的内容会比较长,请坐好小板凳。

如何搭建项目的开发环境

cli 工具本质是一个 npm 库,那么我们就应该按照库的模式搭建项目开发环境。如果你不知道如何搭建,请参考我的文章:开发一个 npm 库应该做哪些工程配置?[2]。如果你不是很想从头搭建开发环境,那么,你可以直接 fork 我的仓库:xwg-cli[3]

我的项目使用了 ts,但是不复杂,基本和 js 差不多,但是这仍然要求你具备比较好的 ts 知识,否则阅读文章会有一点难度。

需要用到的第三方库

库名作用
commander解析用户在终端输入的命令及参数,例如 create、--help 等
chalk为终端输出的文字增加各种各样的颜色
inquirer给用户提供各种交互,包括输入、选择等,例如提示用户选择项目的创建类型,其可以提供输入框、radio、checkbox 等强大的交互控件
ora增加 loading,例如下载模板时增加 loading
fs-extrafs 模块的增强,支持更强大的文件读写操作
download-git-repo下载模板

chalk

我下载的 chalk 的大版本是 4,类型定义可能和其它版本不一样,具体请以你下载的版本为准。

import chalk from 'chalk';

console.log(chalk.red('这会输出红色的字'))
console.log(chalk.blue('这会输出蓝色的字'))
console.log(chalk.cyan('这会输出青色的字'))
console.log(chalk.cyan.bold('这会输出青色加粗的字'))
console.log(chalk.cyan.italic('这会输出青色斜体的字'))

chalk 改变字体颜色的所有方法定义:

declare type ForegroundColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'gray'
| 'grey'
| 'blackBright'
| 'redBright'
| 'greenBright'
| 'yellowBright'
| 'blueBright'
| 'magentaBright'
| 'cyanBright'
| 'whiteBright';

chalk 改变字体背景色的所有方法定义:

declare type BackgroundColor =
| 'bgBlack'
| 'bgRed'
| 'bgGreen'
| 'bgYellow'
| 'bgBlue'
| 'bgMagenta'
| 'bgCyan'
| 'bgWhite'
| 'bgGray'
| 'bgGrey'
| 'bgBlackBright'
| 'bgRedBright'
| 'bgGreenBright'
| 'bgYellowBright'
| 'bgBlueBright'
| 'bgMagentaBright'
| 'bgCyanBright'
| 'bgWhiteBright';

chalk 改变字体属性所有方法的定义:

declare type Modifiers =
| 'reset'
| 'bold'
| 'dim'
| 'italic'
| 'underline'
| 'inverse'
| 'hidden'
| 'strikethrough'
| 'visible';

了解这些基本已经足够你开发使用了。

commander

import { program } from 'commander';

program.name 定义命令的名称。

program.name(chalk.cyan('xwg'))
program.name(chalk.cyan('demo'))
1bffaa396f729c1b01f505352325bcf8.jpeg
image.png
058da77987f0df2e55c0ad7904bf18c4.jpeg
image.png

program.usage 定义命令的用法。

program.name(chalk.cyan('xwg')).usage(`${chalk.yellow('<command>')} [options]`);
a44bda995da2e1bf7641323e76f7f6d2.jpeg
image.png

program.version 定义 --version

program.version(
  `\r\n  ${chalk.cyan.bold(VERSION)}
  ${chalk.cyan.bold(BRAND_LOGO)}`
);
8615c4ae854e31c5a77282395334a09e.jpeg
image.png

program.on 监听某个参数的执行,并执行回调。

program.on("--help", function () {
    console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
});

当我们监听某个命令时,可以这么写:

program
  .command('create <project-name>') // 这里不能使用 chalk
  .description(chalk.cyan('创建新项目'))
  .option('-f, --force', chalk.red('如果目录已存在将覆盖原目录,请谨慎使用,这会先删除你已存在的项目再进行创建,可能会存在意外情况'))
  .action(这里是一个回调函数,执行这个命令的操作);

command 监听命令,description 设置命令的描述,option 是设置命令的可选参数,比如这里设置了 --force,意思就是是否强制覆盖目录,这个参数是可选的。所以 --help--version 其实也是参数。action 就是这个命令真正的执行的方法,这里我们定义了一个 create 函数,该方法最后就会执行这个函数,在这个函数中我们去执行一系列的包括询问用户、添加 loading、下载模板等操作。

program.parse(process.argv); 是固定写法,在最后执行,就是将 process.argv 传递给 parse,parse 会解析用户在终端输入的一切命令还有参数,并进行执行。

来看完整版:

const runner = () => {
  program.name(chalk.cyan('demo')).usage(`${chalk.yellow('<command>')} [options]`);

  program.version(
    `\r\n  ${chalk.cyan.bold(VERSION)}
    ${chalk.cyan.bold(BRAND_LOGO)}`
  );

  program
    .command('create <project-name>') // 这里不能使用 chalk
    .description(chalk.cyan('创建新项目'))
    .option('-f, --force', chalk.red('如果目录已存在将覆盖原目录,请谨慎使用,这会先删除你已存在的项目再进行创建,可能会存在意外情况'))
    .action(() => {
      // 执行操作
    });

  program.on("--help", function () {
    console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
  });

  program.parse(process.argv);
};

inquirer

具体配置参见官方文档:www.npmjs.com/package/inq…[4]

贴太多代码影响阅读体验,想看具体实现细节和类型定义的可以去看源码。

import Inquirer from 'inquirer';

export const prompt = async (prompts: any[]) => {
  return await new Inquirer.prompt(prompts);
};

/** 询问要创建的项目类型 */
export const askCreateType = async () => {
  const { projectType } = await prompt([
    // 返回值为 Promise
    // 具体配置参见:https://www.npmjs.com/package/inquirer#questions
    {
      type: "list",
      name: "projectType",
      message: "请选择你要创建的项目类型",
      choices: [
        { name: "vue", value: 'vue' },
        { name: "react", value: 'react' },
        { name: "uniapp", value: 'uniapp' },
        { name: "koa", value: 'koa' },
        // { name: "nest", value: 'nest' },
        { name: "library", value: 'library' },
      ],
    },
  ]);

  return projectType;
};

ora

import ora from "ora";
import chalk from "chalk";

const spinner = ora('loading');

spinner.start(); // 开启加载

spinner.succeed('成功');

spinner.fail("请求失败,正在重试...");

download-git-repo

在网上看到其他文章说该库不支持 Promise,需要 util 模块 promisify 一下:

import util from 'node:util';
import download from 'download-git-repo';

export const downloadGitRepo = util.promisify(download);

// 下面是伪代码
downloadGitRepo(`direct: 仓库地址`, 存放的目标地址, { clone: true })

一些前置知识

node_modules 的 bin 目录

我们下载的一些 cli 工具,会放在 node_modules 下一个 .bin 的目录中。虽然其文件没有后缀,但其实就是 js 文件,它会去找对应的代码执行,下面是 bin 目录下 nodemon 命令的文件内容:

cc4979a25b7862fbab3bd3cffeb40672.jpeg
image.png

当我们执行命令时 node 会从这个目录中找到我们要执行的命令。那为什么这些包会在这里生成一个命令文件呢?主要是因为可以在 package.json 中配置字段 bin

2e7ebcbe366f79c72ba27dbed57a9c09.jpeg
image.png

我们这里配置一个命令 xwg,它在执行时会去寻找 bin 文件夹下的 cli.js 文件进行执行。这也就是为什么我的包名是 xwg-cli,但是我却可以通过 xwg 来执行命令。我们还可以创建多个,不同的命令对应不同的执行文件:

"bin": {
  "xwg": "./bin/cli.js",
  "ikun": "./bin/ikun.js"
},

必要的注释

接下来也是一个非常重要的点:

45792b1aa98a707e1978db81c01b0a22.jpeg
image.png

可以看到,在 cli.js 的首行,有一行注释:#! /usr/bin/env node。这个注释可不是多余,所有的 node cli 必须在开头包含此注释,否则命令就没法正常运行,这句注释就是指该命令在 node 环境下运行,所以请务必不要省略此注释!

本地测试

我们可以现在 bin/cli.js 下随便写点什么:

#! /usr/bin/env node

console.log('跑起来了')

基于当前项目打开你的终端,运行 npm link,链接你的本地包,此时我们终端运行 xwg 试试:

b1194f75f8cff3998ec8990e8520a730.jpeg
image.png

如果没有权限请记得给你的项目赋予管理员权限。

工程化配置

tsconfig.json

noEmit 必须是 false,否则 tsc 编译后不输出文件。

include 必须包含 types/**/*,否则在 types 下的声明不起效果。

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ESNext", "DOM"],
    "moduleResolution": "Node",
    "strict": true,
    "sourceMap": false,
    "resolveJsonModule": true,
    "isolatedModules": false,
    "esModuleInterop": true,
    "noEmit": false,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "skipLibCheck": true
  },
  "include": ["src", "types/**/*"]
}

nodemon

由于每次更改都要重新执行一遍 node ./lib/index.js 过于麻烦,所以需要借助于 nodemon。

下载 nodemon:yarn add nodemon \-D

新建 nodemon.json

{
  "watch": ["src"], // 监听 src 目录
  "ext": "ts", // 匹配扩展名为 ts 的文件
  "exec": "rimraf lib && tsc --outDir lib --module CommonJS"
}

watch:监听 src 目录。

ext:匹配扩展名为 ts 的文件。

exec:rimraf 是一个 npm 包,作用是删除目录文件,优点是跨平台兼容性好。该命令的意义是:tsc 编译 src 下的 ts 文件并制定输出目录为 lib (--outDir lib),并指定编译目标的模块化规范是 commonjs(--module CommonJS)。

因为有 nodemon.json 的配置文件,我们在 package.json 中可以简写:

"scripts": {
  "dev": "nodemon",
},

每次我们执行 npm run dev 实际上就是执行 nodemon,nodemon 会寻找 nodemon.json 并执行里面的配置,每次当我们修改 src 的 ts 文件它就会删除 lib 并帮助我们重新 tsc 编译一遍。

文件目录结构

3a8b846ae7c2331b8c45167bb76b3c7d.jpeg
image.png
  • bin 文件我们存放命令的执行入口

  • lib 的代码是 src 经过 tsc 编译为 commonjs 的代码

  • src 是我们编写核心代码的地方

  • test 用于存放单元测试

  • types 用于存放 ts 类型声明

  • .editorconfig 编辑器配置

  • .eslintignore eslint 忽略文件

  • .eslintrc eslint 配置文件

  • commitlin.config.js commit 提交信息校验的配置文件,commit message 不对会报错

  • nodemon.json 配置 nodemon,帮助我们保存代码后热更新,不用再执行 node lib/index.js

  • tsconfig.json ts 配置文件

bin/cli.js

#! /usr/bin/env node

/**
 * xwg cli
 * @author xiwenge <1825744594@qq.com>
 * @create 2023/06/24
 */

'use strict';

const runner = require('../lib/index').default;

runner();

不建议把大量的代码堆在 bin 目录下,可能很多教程会这么做,但是这样拆分代码不太简洁美观。我们将所有核心代码放入 lib 目录下,从 lib 引入核心代码来进行运行。

在这里我们引入 runner 函数进行执行。

src/index.ts

import runner from './runner';

export default runner;

src/runner.ts

这里,我们把 command 相关的代码拆出去。

import { program } from 'commander';
import chalk from 'chalk';
import {
  create
} from './commands';
import { BRAND_LOGO, VERSION } from './const';

const runner = () => {
  program.name(chalk.cyan('xwg')).usage(`${chalk.yellow('<command>')} [options]`);

  program.version(
    `\r\n  ${chalk.cyan.bold(VERSION)}
    ${chalk.cyan.bold(BRAND_LOGO)}`
  );

  create();

  program.on("--help", function () {
    console.log(`\r\n终端执行 ${chalk.cyan.bold("xwg <command> --help")} 获取更多命令详情\r\n`);
  });

  program.parse(process.argv);
};

export default runner;

src/const.ts

此目录用于存放我们需要使用到的静态变量。这种艺术字可以访问 ascii 艺术字[5],我们这里使用的字体是 ANSI Shadow

/**
 * 静态变量
 * @author xiwenge <1825744594@qq.com>
 * @create 2023/06/25
 */
import fs from 'fs-extra';
import path from 'node:path';
import util from 'node:util';
import download from 'download-git-repo';

/** 当前根目录 */
export const ROOT_DIR = path.resolve(__dirname, '../');

const { version } = fs.readJSONSync(path.resolve(ROOT_DIR, 'package.json'));

/** https://tooltt.com/art-ascii/ font: ANSI Shadow */
export const BRAND_LOGO = `
  ██╗  ██╗██╗    ██╗ ██████╗      ██████╗██╗     ██╗
  ╚██╗██╔╝██║    ██║██╔════╝     ██╔════╝██║     ██║
  ╚███╔╝ ██║ █╗ ██║██║  ███╗    ██║     ██║     ██║
  ██╔██╗ ██║███╗██║██║   ██║    ██║     ██║     ██║
  ██╔╝ ██╗╚███╔███╔╝╚██████╔╝    ╚██████╗███████╗██║
  ╚═╝  ╚═╝ ╚══╝╚══╝  ╚═════╝      ╚═════╝╚══════╝╚═╝
`;

/** 当前版本号 */
export const VERSION = version;

export const getRepoURL = (tag: string) => {
  return `https://gitee.com/redstone-1/${tag}.git`;
};

export const downloadGitRepo = util.promisify(download);

封装loading和prompt

loading

import ora from "ora";
import chalk from "chalk";
import { LoadingOther } from '../types';

/**
 * 睡觉函数
 * @param {Number} delay 睡眠时间
 */
const sleep = (delay: number) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(false);
    }, delay);
  });
};

/**
 * 加载中方法
 * @param message - 提示信息
 * @param callback - 执行的回调
 * @param projectName - 项目名
 * @returns
 */
export const loading = async (message: string, callback: () => any, other: LoadingOther): Promise<any> => {
  const spinner = ora(message);
  spinner.start(); // 开启加载
  try {
    const res = await callback();
    // 加载成功
    spinner.succeed(
      `${chalk.black.bold('下载成功!执行以下命令打开并运行项目:')}
      \r\n  ${chalk.gray.bold(`cd ${other?.projectName}`)}
      \r\n  ${chalk.gray.bold('npm install')}
      \r\n  ${chalk.gray.bold('npm run dev')}
      \r\n  ${chalk.gray.bold('问题、意见、建议请反馈至:https://github.com/Redstone-1/xwg-cli/issues')}
      `
    );
    return res;
  } catch (error) {
    // 加载失败
    spinner.fail("请求失败,正在重试...");
    await sleep(1000);
    // 重新拉取
    return loading(message, callback, other);
  }
};

prompt

import Inquirer from 'inquirer';

export default async (prompts: any[]) => {
  return await new Inquirer.prompt(prompts);
};

askUser.ts

将所有询问用户的操作都封装在此,这里仅贴出示例:

import prompt from "../../utils/prompt";

// ========== library ==========
/** 询问要创建的项目类型 */
export const askCreateType = async () => {
  const { projectType } = await prompt([
    // 返回值为 Promise
    // 具体配置参见:https://www.npmjs.com/package/inquirer#questions
    {
      type: "list",
      name: "projectType",
      message: "请选择你要创建的项目类型",
      choices: [
        { name: "vue", value: 'vue' },
        { name: "react", value: 'react' },
        { name: "uniapp", value: 'uniapp' },
        { name: "koa", value: 'koa' },
        // { name: "nest", value: 'nest' },
        { name: "library", value: 'library' },
      ],
    },
  ]);

  return projectType;
};
...

create 命令

新建 commands 目录存放各类命令

bdeb4c2e8b0ef52cd5ec6c21bee515dc.jpeg
image.png

拆分 action

将 action 的回调函数 create 拆分出去:

c3e723f91a40b3586b1cc60644bd8f6c.jpeg
image.png

create.ts

这里主要做了一件事,判断要创建的项目目录名是否存在,分别走入不同的分支逻辑:

import path from 'path';
import fs from 'fs-extra';
import dirExistCall from './dirExistCall';
import downloadRepo from './downloadRepo';

/**
 * 创建新项目
 * @param projectName - 项目名
 * @param options - 命令参数
 */
export default async (projectName: string, options: any) => {
  // 获取当前工作目录
  const cwd = process.cwd();
  // 拼接得到项目目录
  const targetDirectory = path.join(cwd, projectName);
  // 判断目录是否存在
  if (fs.existsSync(targetDirectory)) {
    await dirExistCall(options, projectName, targetDirectory);
  } else {
    await downloadRepo(projectName, targetDirectory);
  }
};

dirExistCall.ts

当目录已经存在时,需要询问用户是否覆盖。

import fs from "fs-extra";
import downloadRepo from './downloadRepo';
import { askOverwrite } from './askUser';

/**
 * 如果目录已经存在时调用
 * @param options - 命令参数
 * @param targetDirectory - 目标路径
 */
export default async (options: any, projectName: string, targetDirectory: string) => {
  // 判断是否使用 --force 参数
  if (options.force) {
    // 删除重名目录
    await fs.remove(targetDirectory);
    await downloadRepo(projectName, targetDirectory);
  } else {
    const { isOverwrite } = await askOverwrite();
    // 选择 Overwirte
    if (isOverwrite) {
      // 先删除掉原有重名目录
      await fs.remove(targetDirectory);
      await downloadRepo(projectName, targetDirectory);
    }
  }
};

downloadRepo.ts

当用户将所有的选择都确认后执行下载模板的操作。下面给出部分代码示例,请从下往上阅读:

import {
  askCreateType,
  askNeedTypeScript,
  askIsAgreeCli,
  askVueVersion,
  askNeedUviewUI,
} from "./askUser";
import { loading } from "../../utils/loading";
import { getRepoURL, downloadGitRepo } from "../../const";
import { TProjectType } from '../../types';
import chalk from "chalk";

/**
 * 下载 vue 模板
 * @param projectName - 项目名称
 * @param targetDirectory - 目标存储路径
 */
const downloadVueTemplate = async (projectName: string, targetDirectory: string) => {
  let repoURL = '';
  const needTypeScript = await askNeedTypeScript();
  if (needTypeScript) {
    repoURL = getRepoURL('vue-template-typescript');
  }
  if (!needTypeScript) {
    repoURL = getRepoURL('vue-template');
  }
  await loading('正在下载模板,请稍后...', () => downloadGitRepo(`direct:${repoURL}`, targetDirectory, { clone: true }), { projectName });
};

/**
 * 执行创建命令
 * @param projectType - 项目类型 "library" | "react" | "vue" | "uniapp" | "koa" | nest""
 * @param projectName - 项目名称
 * @param targetDirectory - 目标存储路径
 */
const execCreate = async (projectType: TProjectType, projectName: string, targetDirectory: string) => {
  switch (projectType) {
    case 'library':
      await downloadLibraryTemplate(projectName, targetDirectory);
      break;
    case 'vue':
      await downloadVueTemplate(projectName, targetDirectory);
      break;
    case 'react':
      await downloadReactTemplate(projectName, targetDirectory);
      break;
    case 'uniapp':
      await downloadUniappTemplate(projectName, targetDirectory);
      break;
    case 'koa':
      await downloadKoaTemplate(projectName, targetDirectory);
      break;
    case 'nest':
      console.log(chalk.gray.bold(`\r\n  开发中,敬请期待...\r\n`));
      break;
  }
};

/**
 * 创建项目
 * @param projectName - 项目名称
 * @param targetDirectory - 目标存储路径
 */
export default async (projectName: string, targetDirectory: string) => {
  console.log(chalk.red.bold(`\r\n  请注意:本 cli 下大部分模板采用 vite 构建,node 版本需要 14.18+ 或 16+ 或更高\r\n`));

  const projectType = await askCreateType();
  await execCreate(projectType, projectName, targetDirectory);
};

到这里我们就完成了 create 命令,现在执行 xwg create xxx 就可以创建项目了。

写在最后

贴了太多代码看起来可能不是很顺畅,建议直接去看源码,更能快速上手学习。

在其他的一些高级 cli 教程里,使用 babel 进行各种配置文件的生成和写入,这是更高级的内容,暂时做不了。目前比较呆的处理方式是写一堆模板,不同的配置下载不同的模板。引入 babel 可以减少模板仓库数量,根据用户的选择动态的删除、插入各种文件,具备更强大更灵活的处理能力,但是目前不具备 babel 的运用能力,后续再考虑进行升级。若有愿意共建的朋友,欢迎 Pr。

Node 社群

 
 

我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

f5d810b8cbcc30694d470dae36b1965e.png

“分享、点赞、在看” 支持一下
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值