如何使用CanJS构建实时GitHub问题任务列表

CanJS是一个前端库的集合,这些前端库使构建可长期维护的复杂且创新的Web应用程序变得更加容易。 它分为数十个单独的程序包,因此您可以在应用程序中选择所需的内容,而不会被100kb +的巨大依赖所困扰。

CanJS通过以下关键软件包来提升MVVM(Model-View-ViewModel)体系结构:

在本教程中,我们将制作一个使用GitHub存储库的问题列表作为源的待办事项列表应用程序。 借助GitHub的Webhook API ,我们的应用将实时更新,并且由于jQuery UI的可排序交互 ,我们将能够对问题进行重新排序

您可以在GitHub上找到此应用程序的完成源代码。 最终的应用程序如下所示:

在我们的示例应用程序中添加问题并对其进行排序的Gif

如果您有兴趣将自己的JavaScript技能提高到一个新的水平,请注册SitePoint Premium,并查看我们的最新书籍《 Modern JavaScript》

CanJS中的MVVM

在开始本教程的项目之前,让我们深入了解MVVM在CanJS应用程序中的含义。

数据模型

MVVM中的“模型”适用于您的数据模型:应用程序中数据的表示。 我们的应用程序处理单个问题和问题列表,因此这些是我们模型中的数据类型。

在CanJS中,我们分别使用can-define / list / listcan-define / map / map表示数组和对象。 这些是可观察的数据类型,当它们更改时将自动更新View或ViewModel(在MVVM中)。

例如,我们的应用将具有以下Issue类型:

import DefineMap from 'can-define/map/map';
const Issue = DefineMap.extend('Issue', {
  id: 'number',
  title: 'string',
  sort_position: 'number',
  body: 'string'
});

每个Issue实例将具有四个属性: idtitlesort_positionbody 。 设置值后,除非该值为nullundefined ,否则can-define/map/map会将其转换为上面指定的类型。 例如,将id设置为字符串"1"将为id属性赋予数字值1 ,而将其设置为null则实际上将其设置为null

我们将为一系列问题定义一种类型:

import DefineList from 'can-define/list/list';
Issue.List = DefineList.extend('IssueList', {
  '#': Issue
});

can-define/list/list上的属性会将can-define/list/list任何项目转换为指定的类型,因此Issue.List任何项目Issue.List将是Issue实例。

查看模板

Web应用程序中的“视图”是与用户交互的HTML用户界面。 CanJS可以使用几种不同的模板语法来呈现HTML,包括can-stache ,它类似于MustacheHandlebars

这是can-stache模板的简单示例:

<ol>
  {{#each issues}}
    <li>
      {{title}}
    </li>
  {{/each}}
</ol>

在上面的例子中,我们使用{{#each}}通过列表迭代issues ,然后显示title每个问题与{{title}} 。 对issues列表或问题标题的任何更改都将导致DOM更新(例如,如果将新问题添加到列表中,则将li添加到DOM中)。

查看模型

MVVM中的ViewModel是模型和视图之间的粘合代码。 ViewModel提供了模型中无法包含但视图必需的任何逻辑。

在CanJS中, can-stache使用ViewModel渲染can-stache模板。 这是一个非常简单的示例:

import stache from 'can-stache';
const renderer = stache('{{greeting}} world');
const viewModel = {greeting: 'Hello'};
const fragment = renderer(viewModel);
console.log(fragment.textContent);// Logs “Hello world”

组件

将所有这些东西联系在一起的概念是一个组件(或自定义元素)。 组件可用于将功能分组在一起,并使它们在整个应用程序中可重用。

在CanJS中, can组件由一个视图( can-stache文件),一个视图模型( can-define/map/map )和一个(可选)可以侦听JavaScript事件的对象组成。

import Component from 'can-component';
import DefineMap from 'can-define/map/map';
import stache from 'can-stache';

const HelloWorldViewModel = DefineMap.extend('HelloWorldVM', {
  greeting: {value: 'Hello'},
  showExclamation: {value: true}
});

Component.extend({
  tag: 'hello-world',
  view: stache('{{greeting}} world{{#if showExclamation}}!{{/if}}'),
  ViewModel: HelloWorldViewModel,
  events: {
    '{element} click': () => {
      this.viewModel.showExclamation = !this.viewModel.showExclamation;
    }
  }
});

const template = stache('hello-world');
document.body.appendChild(template);

在上面的示例中,我们的模板将显示“ Hello world!” 或只是“ Hello world”(没有感叹号),具体取决于用户是否单击了我们的自定义元素。

这四个概念是构建CanJS应用所需的全部知识! 我们的示例应用程序将使用这四个想法来构建成熟的MVVM应用程序。

本教程的先决条件

在开始之前,请安装最新版本的Node.js。 我们将使用npm安装后端服务器,该服务器将处理与GitHub API的通信。

此外,如果您还没有GitHub帐户,请注册一个。

设置我们的本地项目

首先,为项目创建一个新目录并切换到该新目录:

mkdir canjs-github
cd canjs-github

现在,让我们创建项目所需的文件:

touch app.css app.js index.html

我们将app.css用于样式,将app.js用于JavaScript,将index.html用于用户界面(UI)。

CanJS Hello World

让我们开始编码吧! 首先,我们将其添加到我们的index.html文件中:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>CanJS GitHub Issues To-Do List</title>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
  <link rel="stylesheet" href="app.css">
</head>
<body>

<script type="text/stache" id="app-template">
  <div class="container">
    <div class="row">
      <div class="col-md-8 col-md-offset-2">
        <h1 class="page-header text-center">
          {{pageTitle}}
        </h1>
      </div>
    </div>
  </div>
</script>

<script type="text/stache" id="github-issues-template">
</script>

<script src="https://unpkg.com/jquery@3/dist/jquery.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<script src="https://unpkg.com/can@3/dist/global/can.all.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script src="app.js"></script>
</body>
</html>

它有很多不同的部分,所以让我们分解一下:

  • head中的两个link元素是我们项目的样式表。 我们将Bootstrap用于某些基本样式,并在app.css进行一些自定义
  • 第一个script元素(具有id="app-template" )包含我们应用程序的根模板
  • 第二个script元素(具有id="github-issues-template" )将包含我们将在本教程后面创建的github-issues组件的模板。
  • 页面末尾的script元素将加载我们的依赖项:jQuery,jQuery UI,CanJS,Socket.io和我们的应用程序代码

在我们的应用程序中,我们将使用jQuery UI (取决于jQuery )通过拖放对问题进行排序。 我们包含了can.all.js因此我们可以访问每个CanJS模块 ; 通常,您可能希望使用StealJSwebpack之 模块加载器 ,但这超出了本文的范围。 我们将使用Socket.io从GitHub接收事件以实时更新我们的应用程序。

接下来,让我们向app.css文件中添加一些样式:

form {
  margin: 1em 0 2em 0;
}

.list-group .drag-background {
  background-color: #dff0d8;
}

.text-overflow {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

最后,让我们将一些代码添加到我们的app.js文件中:

var AppViewModel = can.DefineMap.extend('AppVM', {
  pageTitle: {
    type: "string",
    value: "GitHub Issues",
  }
});

var appVM = new AppViewModel();
var template = can.stache.from('app-template');
var appFragment = template(appVM);
document.body.appendChild(appFragment);

让我们分解一下JavaScript:

  • can.DefineMap用于声明自定义可观察对象类型
  • AppViewModel是可观察对象类型,将用作我们应用程序的根视图模型
  • pageTitle是所有AppViewModel实例的属性,默认为GitHub Issues
  • appVM是我们应用程序视图模型的新实例
  • can.stache.fromscript标签的内容转换为呈现模板的函数
  • appFragment是带有appVM数据的渲染模板的文档片段
  • document.body.appendChild使用DOM节点并将其附加到HTML正文

注意:我们页面中的can.all.js脚本会生成一个can全局变量,可用于访问任何CanJS模块。 例如, can-stache模块可用于我们的脚本,如can.stache

如果在浏览器中打开index.html ,您将看到类似以下内容:

错误加载Socket.IO的示例应用程序的屏幕截图

控制台中有一个错误,因为我们尚未设置实时Socket.io服务器。 接下来,让我们开始。

设置我们的服务器

只要存储库中发生任何更改, GitHub的Webhooks API即可发送服务器通知。 我没有花时间编写服务器代码,而是制作了github-issue-server npm模块 ,该模块将:

  • 设置一个ngrok服务器以接收GitHub Webhook事件
  • 在用户界面中创建问题时,向GitHub API发出经过身份验证的请求
  • 使用Socket.io与我们的UI进行实时通信
  • 在我们的项目目录中提供文件
  • 为每个问题添加一个sort_position属性
  • 将我们的问题列表及其sort_position在本地issues.json文件中

为了使服务器通过身份验证的请求与GitHub通信,我们需要创建一个个人访问令牌

  1. 转到github.com/settings/tokens/new
  2. 输入令牌描述 (我称我为“ CanJS GitHub Issue To-do List”)
  3. 选择public_repo范围
  4. 点击生成令牌
  5. 在下一页上,单击令牌旁边的“ 复制令牌”剪贴板图标

现在我们可以安装服务器了。 我们将使用npm 创建package.json并安装github-issue-server

npm init -y
npm install github-issue-server

要启动我们的服务器,请运行以下命令,将ACCESS_TOKEN替换为您从GitHub复制的个人访问令牌:

node node_modules/github-issue-server/ ACCESS_TOKEN

您的服务器将启动,并显示类似以下内容:

Started up server, available at:
  http://localhost:8080/
Started up ngrok server, webhook available at:
  https://829s1522.ngrok.io/api/webhook

ngrok服务器地址将具有您唯一的另一个子域。

现在,如果我们在浏览器中打开localhostngrok.io地址,我们将看到与以前相同的主页,除了这次控制台中不会出现任何错误:

我们的示例应用程序的屏幕快照,在DevTools控制台中没有错误

创建一个GitHub Issues组件

在CanJS中, 组件是具有视图 (stache模板)和视图模型 (将数据模型连接到视图)的自定义元素。 组件对于将功能分组在一起并使其在整个应用程序中可重用非常有用。

让我们创建一个github-issues组件,该组件将用于列出所有GitHub问题并添加新问题!

首先,我们将其添加到app.js文件的顶部:

var GitHubIssuesVM = can.DefineMap.extend('GitHubIssuesVM', {
  pageTitle: 'string'
});

can.Component.extend({
  tag: 'github-issues',
  view: can.stache.from('github-issues-template'),
  ViewModel: GitHubIssuesVM
});

GitHubIssuesVM被定义为我们组件的视图模型。 组件的每个实例将具有自己的pageTitle属性,该属性将在HTML视图中呈现。

其次,让我们为github-issues元素定义模板:

<script type="text/stache" id="github-issues-template">
  <h1 class="page-header text-center">
    {{pageTitle}}
  </h1>
</script>

注意{{pageTitle}}语法,该语法将我们的视图模型中的pageTitle呈现为模板。

最后,让我们替换HTML中的标头:

<h1 class="page-header text-center">
  {{pageTitle}}
</h1>

…加上我们新的自定义元素:

<github-issues {page-title}="pageTitle" />

在上面的代码中,我们将pageTitle属性从应用程序的视图模型传递到github-issues组件。 {page-title}语法是从父级模板到子级组件的单向绑定 ,这意味着父级中的任何更改都将传播到子级,但子级中的任何更改都不会影响父级。 CanJS支持单向和双向数据绑定。 稍后我们将查看双向数据绑定的示例。

我们的页面应该与以前完全一样,只是现在它具有以下HTML结构:

带有github-issues自定义元素的DOM屏幕截图

设置GitHub存储库

我们的应用程序将从GitHub存储库(repo)中的问题中列出待办事项,因此我们需要为我们的应用程序配置GitHub repo。

如果您已经有要使用的仓库,那就太好了! 否则, 现在创建一个

现在我们有了一个仓库 ,转到其“设置”页面,单击“ Webhooks” ,然后单击“ 添加webhook” 。 验证后,您可以填写以下表格:

  • ngrok服务器地址从本地服务器复制到有效负载URL字段(该地址类似于https://829s1522.ngrok.io/api/webhook
  • 选择application/json作为内容类型
  • 单击“ 让我选择单个事件”,然后选择“ 问题”复选框
  • gfgf
  • 单击添加webhook按钮以完成该过程

现在,只要您的回购中的问题列表发生更改,本地服务器就会收到这些Webhook事件。 让我们测试一下!

转到GitHub中的Issues标签,在GitHub存储库中创建问题。 如果您创建一个称为“测试问题”的问题,您将在命令行界面中看到以下消息:

从GitHub收到了针对“测试问题”的“未解决”行动

在命令行上运行的服务器的屏幕快照

列出GitHub问题

现在我们的GitHub存储库中有一些问题,让我们在UI中显示这些问题!

首先,我们将创建一个可观察的Issue类型,它将作为我们的问题数据的模型。 将此添加到您的app.js文件的顶部:

var Issue = can.DefineMap.extend('Issue', {
  seal: false
}, {
  id: 'number',
  title: 'string',
  sort_position: 'number',
  body: 'string'
});

每个Issue实例将具有idtitlesort_positionbody属性。 由于GitHub问题除了我们在此处建模的属性外还有许多其他属性,因此我们将seal设置为false这样当其他属性通过GitHub API时不会引发错误。

其次,让我们为问题数组创建一个can.DefineList类型:

Issue.List = can.DefineList.extend('IssueList', {
  '#': Issue
});

第三,我们将配置一个can-set.Algebra,以便can-connect知道两个特殊属性: id是每个问题的唯一标识符,并且我们将sortIssue.getList一起使用以特定顺序检索问题。

Issue.algebra = new can.set.Algebra(
  can.set.props.id('id'),
  can.set.props.sort('sort')
);

最后,我们将IssueIssue.List类型连接到我们的服务器端点。 确保用回购信息替换GITHUB_ORG / GITHUB_REPO

Issue.connection = can.connect.superMap({
  url: '/api/github/repos/GITHUB_ORG/GITHUB_REPO/issues',
  Map: Issue,
  List: Issue.List,
  name: 'issue',
  algebra: Issue.algebra
});

当我们调用can.connect.superMap时会将某些CRUD(创建,读取,更新和删除)方法添加到我们的Issue对象中。 这些方法中包括getList ,可以调用该方法来获取该类型的所有实例的列表。

在我们的应用程序中,我们将使用Issue.getList从服务器中获取所有问题。 让我们更新我们的GitHubIssuesVM使其具有issuesPromise属性:

var GitHubIssuesVM = can.DefineMap.extend('GitHubIssuesVM', {
  issuesPromise: {
    value: function() {
        return Issue.getList({
          sort: 'sort_position'
        });
    }
  },
  issues: {
    get: function(lastValue, setValue) {
      if (lastValue) {
        return lastValue;
      }
      this.issuesPromise.then(setValue);
    }
  },
  pageTitle: 'string'
});

issuesPromise属性是无极返回由Issue.getList ; 我们将sort_position指定为sort属性,以便列表按该属性保持排序。 issues属性将在解决后成为Promise的值。

现在让我们修改index.htmlgithub-issues-template

  <div class="list-group">
    {{#if issuesPromise.isPending}}
      <div class="list-group-item list-group-item-info">
        <h4>Loading…</h4>
      </div>
    {{/if}}
    {{#if issuesPromise.isRejected}}
      <div class="list-group-item list-group-item-danger">
        <h4>Error</h4>
        <p>{{issuesPromise.reason}}</p>
      </div>
    {{/if}}
    {{#if issuesPromise.isResolved}}
      {{#if issues.length}}
        <ol class="list-unstyled">
          {{#each issues}}
            <li class="list-group-item">
              <h4 class="list-group-item-heading">
                {{title}} <span class="text-muted">#{{number}}</span>
              </h4>
              <p class="list-group-item-text text-overflow">
                {{body}}
              </p>
            </li>
          {{/each}}
        </ol>
      {{else}}
        <div class="list-group-item list-group-item-info">
            <h4>No issues</h4>
        </div>
      {{/if}}
    {{/if}}
  </div>

can-stache模板中,我们可以将{{#if}}用作条件,因此对于问题清单的承诺是isPendingisRejected还是isResolved ,我们有三个主要块。 在isResolved情况下,我们将使用{{#each}}遍历一系列问题,否则我们将显示一条没有问题的消息。

现在,当您重新加载页面时,您将看到相同的问题列表!

带有GitHub问题列表的示例应用程序的屏幕截图

创建GitHub问题

让我们添加一个用于创建带有标题和描述的新期刊的表格。 然后,我们将通过GitHub的API创建一个新期刊。

首先,让我们在index.html github-issues-template模板中的h1下添加一个表单:

  <form ($submit)="send()">
    <div class="form-group">
      <label for="title" class="sr-only">Issue title</label>
      <input class="form-control" id="title" placeholder="Issue title" type="text" {($value)}="title" />
    </div>
    <div class="form-group">
      <label for="body" class="sr-only">Issue description</label>
      <textarea class="form-control" id="body" placeholder="Issue description" {($value)}="body"></textarea>
    </div>
    <button class="btn btn-primary" type="submit">Submit issue</button>
  </form>

上面的代码片段使用了一些我们尚未谈到的CanJS功能:

  • ($submit)是一个DOM事件侦听器 ,只要提交表单,它就会在我们的视图模型中调用send()函数
  • {($value)}="title"{($value)}="body"都是双向绑定的值inputvalue更改时,视图模型将更新,反之亦然

其次,让我们将app.jsGitHubIssuesVM更新为具有三个新属性:

var GitHubIssuesVM = can.DefineMap.extend('GitHubIssuesVM', {
  issuesPromise: {
    value: function() {
        return Issue.getList({
          sort: 'sort_position'
        });
    }
  },
  issues: {
    get: function(lastValue, setValue) {
      if (lastValue) {
        return lastValue;
      }
      this.issuesPromise.then(setValue);
    }
  },
  pageTitle: 'string',
  title: 'string',
  body: 'string',
  send: function() {
    var firstIssue = (this.issues) ? this.issues[0] : null;
    var sortPosition = (firstIssue) ? (Number.MIN_SAFE_INTEGER + firstIssue.sort_position) / 2 : 0;

    new Issue({
        title: this.title,
        body: this.body,
        sort_position: sortPosition
    }).save().then(function() {
        this.title = this.body = '';
    }.bind(this));
  }
});

除了新期刊的bodytitle属性外,我们还添加了一个send()方法来创建新期刊。 它接受issues列表,因此可以计算新问题的sort_position :我们希望它在第一个问题之前。 一旦有了新发行版的所有值,就可以调用new Issue()创建它,调用.save()将其发布到我们的服务器,然后等待Promise解决; 如果成功,我们将重置titlebody以便清除表格!

最后,让我们更新app.jsgithub-issues组件,使其具有一个新的events对象:

can.Component.extend({
  tag: 'github-issues',
  view: can.stache.from('github-issues-template'),
  ViewModel: GitHubIssuesVM,
  events: {
    '{element} form submit': function(element, event) {
      event.preventDefault();
    }
  }
});

can-componentevents属性用于侦听要触发的表单的Submit事件 。 我们不希望用户提交表单时重新加载页面,因此我们调用preventDefault()来取消默认的表单提交行为。

现在我们可以添加一个问题,并在GitHub UI中看到它! 更重要的是,该问题出现在问题列表的底部,这要归功于集合代数!

通过我们的应用添加问题并将其显示在GitHub上的Gif

添加实时更新

我们的应用程序可以向GitHub发送新问题,但是从GitHub进行的更改不会更新我们的应用程序。 让我们使用Socket.IO添加一些实时更新!

app.js ,让我们在设置Issue.connection之后添加以下代码:

var socket = io();
socket.on('issue created', function(issue) {
  Issue.connection.createInstance(issue);
});
socket.on('issue removed', function(issue) {
  Issue.connection.destroyInstance(issue);
});
socket.on('issue updated', function(issue) {
  Issue.connection.updateInstance(issue);
});

当问题被创建,删除或更新时,我们的本地服务器会发出三个不同的事件。 然后,我们的事件侦听器调用createInstancedestroyInstanceupdateInstance来修改Issue数据模型。 由于每个实例Issue是观察到的 Issue.List是可观的,CanJS会自动更新我们的应用程序的任何部分,在任何参考Issue模式!

当我们重新加载页面并通过GitHub的UI进行更改时,我们将在UI中看到相同的更改!

在GitHub.com上添加问题并在示例应用程序中显示该问题的Gif

重新排序问题

现在,让我们添加一些拖放功能来组织问题! 设置我们的本地服务器是为了在问题列表的顺序更改时将一个issues.json文件保存到我们的项目目录中,因此我们需要做的就是更新我们的应用程序,使其具有一些用于对问题进行重新排序的控件以及一些为它们分配新逻辑的逻辑sort_position

在上面部分中添加的Socket.IO代码之后,让我们添加以下内容:

can.view.callbacks.attr('sortable-issues', function(element) {
  $(element).sortable({
    containment: 'parent',
    handle: '.grab-handle',
    revert: true,
    start: function(event, ui) {
      var draggedElement = ui.item;
      draggedElement.addClass('drag-background');
    },
    stop: function(event, ui) {
      var draggedElement = ui.item;
      draggedElement.removeClass('drag-background');
    },
    update: function(event, ui) {
      var draggedElement = ui.item[0];
      var draggedIssue = can.data.get.call(draggedElement, 'issue');
      var nextSibling = draggedElement.nextElementSibling;
      var previousSibling = draggedElement.previousElementSibling;
      var nextIssue = (nextSibling) ? can.data.get.call(nextSibling, 'issue') : {sort_position: Number.MAX_SAFE_INTEGER};
      var previousIssue = (previousSibling) ? can.data.get.call(previousSibling, 'issue') : {sort_position: Number.MIN_SAFE_INTEGER};
      draggedIssue.sort_position = (nextIssue.sort_position + previousIssue.sort_position) / 2;
      draggedIssue.save();
    }
  });
});

ew! 让我们分解一下:

  • can.view.callbacks用于在将新属性元素添加到DOM时注册回调。 在我们的代码中,只要将sortable-issues属性添加到元素,就会调用我们的函数。
  • 我们正在使用jQuery UI的可排序交互来处理DOM元素的拖放。 我们已经使用containmenthandlerevert选项对其进行了配置。
  • 每当用户开始拖动问题时,就会触发启动函数,这将向DOM元素添加一个类。
  • 每当用户删除问题时,将触发stop函数,这将删除我们在start添加的类。
  • 排序完全停止并且DOM已更新后,将调用update 。 我们的函数获取所拖动问题以及紧接前后问题的Issue模型数据,因此它可以重新计算两个问题之间的sort_position 。 分配sort_position属性后,我们调用save()将更新的问题数据放入本地服务器。

现在,让我们更新index.html中问题的<ol>

        <ol class="list-unstyled" sortable-issues>
          {{#each issues}}
            <li class="list-group-item" {{data('issue', this)}}>
              {{^is issues.length 1}}
                <span class="glyphicon glyphicon-move grab-handle pull-right text-muted" aria-hidden="true"></span>
              {{/is}}
              <h4 class="list-group-item-heading">
                {{title}} <span class="text-muted">#{{number}}</span>
              </h4>
              <p class="list-group-item-text text-overflow">
                {{body}}
              </p>
            </li>
          {{/each}}
        </ol>

我们添加了一些新内容:

  • 该列表位于DOM中后, sortable-issues属性将导致我们在app.js定义的回调被调用。
  • {{data('issue', this)}}会将问题数据附加到DOM元素,因此我们可以在我们的sortable-issues回调中获取它。
  • 如果列表中有多个问题,则{{^is issues.length 1}}部分将添加一个抓取手柄以移动问题。

现在,当我们重新加载页面时,我们将看到每个问题的抓取手柄,我们可以捡起它们来重新排序问题!

通过拖放操作在示例应用程序中重新排序问题的Gif

进一步阅读

我们已经使用CanJS成功为GitHub问题建立了一个实时待办事项清单! 如果我想了解更多有关CanJS的信息,请在CanJS.com查看以下一些指南:

感谢您抽出宝贵时间阅读本教程。 如果您需要任何帮助,请不要害怕在Gitter上 ,在CanJS论坛发问向我发送推文或在下面发表评论!

本文由Camilo Reyes进行同行评审。 感谢所有SitePoint的同行评审人员使SitePoint内容达到最佳状态!

From: https://www.sitepoint.com/real-time-github-issue-list-canjs/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值