react单页应用权限控制_我如何构建单页React应用程序

react单页应用权限控制

by Gooi Ying Chyi

通过Gooi Ying Chyi

我如何构建单页React应用程序 (How I architected a single-page React application)

具有数据结构,组件以及与Redux的集成 (With Data Structures, Components and integration with Redux)

I recently built a single-page application that interacts with a backend JSON API server. I chose to use React to deepen my understanding of React fundamentals and how each tool can help in building a scalable frontend.

我最近构建了一个与后端JSON API服务器进行交互的单页应用程序。 我选择使用React来加深我对React基础知识以及每种工具如何帮助构建可扩展前端的理解。

The stack of this application consists of:

该应用程序的堆栈包括:

  • Frontend with React/Redux

    带有React / Redux的前端
  • A backend JSON API server with Sinatra, integrated with Postgres for database persistence

    带有Sinatra的后端JSON API服务器,与Postgres集成以实现数据库持久性
  • An API client that fetches data from OMDb API, written in Ruby

    一个API客户端,该客户端从Ruby编写的OMDb API中获取数据

For this post, we’ll assume that we have the backend completed. So let’s focus on how design decisions are made on the frontend.

对于这篇文章,我们假设我们已经完成了后端。 因此,让我们集中讨论如何在前端做出设计决策。

Side note: The decisions presented here are for reference only and may vary depending on the needs of your application. An example OMDb Movie Tracker app is used here for demonstration.
旁注:此处提出的决定仅供参考,可能会因您的应用程序需求而异。 这里使用示例OMDb Movie Tracker应用进行演示。

应用程式 (The App)

The application consists of a search input form. A user can input a movie title to return a movie result from OMDb. The user can also save a movie with a rating and short comment into a favorites list.

该应用程序包含一个搜索输入表单。 用户可以输入电影标题以从OMDb返回电影结果。 用户还可以将带有评分和简短评论的电影保存到收藏夹列表中。

To view the final app, click here. To view the source code, click here.

要查看最终的应用程序, 请单击此处 要查看源代码,请单击此处

When a user searches a movie on the homepage, it looks like this:

当用户在首页上搜索电影时,看起来像这样:

For the sake of simplicity, we’ll only focus on designing the core features of the application in this article. You can also skip to Part II: Redux of the series.

为了简单起见,在本文中我们将只着重于设计应用程序的核心功能。 您也可以跳至第二部分:Redux 系列中的。

数据结构 (Data Structure)

Defining appropriate data structures should be one of the most important aspects of designing an app. This should come as the first step, as it determines not only how the frontend should render the elements, but also how the API server should return the JSON responses.

定义适当的数据结构应该是设计应用程序最重要的方面之一。 这应该作为第一步,因为它不仅确定前端应如何呈现元素,而且还要确定API服务器应如何返回JSON响应。

For this app, we’ll need two main pieces of information to properly render our UI: a single movie result and a list of favorited movies.

对于此应用程序,我们需要两条主要信息才能正确呈现我们的UI: 单个电影结果收藏的电影列表

电影结果对象 (Movie result object)

A single movie result will contain information such as the title, year, description, and poster image. With this, we need to define an object that can store these attributes:

单个电影结果将包含诸如标题,年份,描述和海报图像之类的信息。 这样,我们需要定义一个可以存储以下属性的对象:

{  "title": "Star Wars: Episode IV - A New Hope",  "year": "1977",  "plot": "Luke Skywalker joins forces with a Jedi Knight...",  "poster": "https://m.media-amazon.com/path/to/poster.jpg",  "imdbID": "tt0076759"}

The poster property is simply a URL to the poster image that will be displayed in the results. If there’s no poster available for that movie, it will be “N/A”, which we will display a placeholder. We will also need an imdbID attribute to uniquely identify each movie. This is useful for determining whether or not a movie result already exists in the favorites list. We’ll explore later on how it works.

poster属性只是将在结果中显示的海报图像的URL。 如果没有该电影的海报,它将为“ N / A”,我们将显示一个占位符。 我们还将需要一个imdbID属性来唯一标识每部电影。 这对于确定收藏夹列表中是否已经存在电影结果很有用。 稍后我们将探讨其工作原理。

收藏列表 (Favorites list)

The favorites list will contain all of the movies saved as favorites. The list will look something like this:

收藏夹列表将包含所有保存为收藏夹的电影。 该列表将如下所示:

[  { title: "Star Wars", year: "1977", ..., rating: 4 },  { title: "Avatar", year: "2009", ..., rating: 5 }]

Keep in mind that we’ll need to look up a specific movie from the list, and the time complexity for this approach is O(N). While it works fine for smaller datasets, imagine having to search for a movie in a favorites list that grows indefinitely.

请记住,我们需要从列表中查找特定电影,这种方法的时间复杂度为O(N) 。 尽管它对于较小的数据集可以正常工作,但想象一下必须在无限期增长的收藏夹列表中搜索电影。

With this in mind, I chose to go with a hash table with keys as imdbID and values as favorited movie objects:

考虑到这一点,我选择使用一个哈希表,其键为imdbID ,值作为收藏的电影对象:

{  tt0076759: {    title: "Star Wars: Episode IV - A New Hope",    year: "1977",    plot: "...",    poster: "...",    rating: "4",    comment: "May the force be with you!",  },  tt0499549: {    title: "Avatar",    year: "2009",    plot: "...",    poster: "...",    rating: "5",    comment: "Favorite movie!",  }}

With this, we can look up a movie in the favorites list in O(1) time by its imdbID.

这样,我们可以在O(1)时间通过其imdbID在收藏夹列表中查找电影。

Note: the runtime complexity is probably not going to matter in most cases since the datasets are usually small on the client-side. We are also going to perform slicing and copying (also O(N) operations) in Redux anyway. But as an engineer, it’s good to be aware of potential optimizations that we can perform.
注意:在大多数情况下,运行时复杂度可能无关紧要,因为客户端上的数据集通常很小。 无论如何,我们还将在Redux中执行切片和复制(也是O(N)操作)。 但是,作为一名工程师,最好意识到我们可以执行的潜在优化。

组件 (Components)

Components are at the heart of React. We’ll need to determine which ones that will interact with the Redux store, and which ones that are only for presentation. We can also reuse some of the presentational components too. Our component hierarchy will look something like this:

组件是React的核心。 我们需要确定哪些将与Redux存储交互,哪些仅用于演示。 我们还可以重用某些演示组件。 我们的组件层次结构如下所示:

主页 (Main page)

We designate our App component at the top level. When the root path is visited, it needs to render the SearchContainer. It also needs to display flash messages to the user and handle the client-side routing.

我们在顶层指定App组件。 当访问根路径时,它需要呈现SearchContainer 。 它还需要向用户显示Flash消息并处理客户端路由。

The SearchContainer will retrieve the movie result from our Redux store, providing information as props to MovieItem for rendering. It will also dispatch a search action when a user submits a search in SearchInputForm. More on Redux later.

SearchContainer将从我们的Redux商店中检索电影结果,并提供相关信息作为MovieItem进行渲染的道具。 当用户在SearchInputForm中提交搜索时,它还将调度搜索操作。 稍后再介绍Redux。

添加到收藏夹表单 (Add To Favorites Form)

When the user clicks on the “Add To Favorites” button, we will display the AddFavoriteForm, a controlled component.

当用户单击“添加到收藏夹”按钮时,我们将显示AddFavoriteForm ,这是一个受控组件

We are constantly updating its state whenever a user changes the rating or input text in the comment text area. This is useful for validation upon form submission.

每当用户更改等级或在注释文本区域中输入文本时,我们都会不断更新其状态。 这对于表单提交时的验证很有用。

The RatingForm is responsible to render the yellow stars when the user clicks on them. It also informs the current rating value to AddFavoriteForm.

RatingForm负责在用户单击黄色星形时对其进行渲染。 它还会将当前评级值通知给AddFavoriteForm

收藏夹标签 (Favorites Tab)

When a user clicks on the “Favorites” tab, the App renders FavoritesContainer.

当用户点击“收藏夹”选项卡上,在App呈现FavoritesContainer。

The FavoritesContainer is responsible for retrieving the favorites list from the Redux store. It also dispatches actions when a user changes a rating or clicks on the “Remove” button.

收藏夹负责从Redux存储中检索收藏夹列表。 当用户更改评分或单击“删除”按钮时,它还会调度操作。

Our MovieItem and FavoritesInfo are simply presentational components that receive props from FavoritesContainer.

我们的MovieItem收藏夹信息只是表示性组件,可以从收藏夹 容器中接收道具。

We’ll reuse the RatingForm component here. When a user clicks on a star in the RatingForm, the FavoritesContainer receives the rating value and dispatches an update rating action to the Redux store.

我们将在此处重用RatingForm组件。 当用户单击RatingForm中的星形时FavoritesContainer会收到评分值,并向Redux存储分派更新评分操作。

Redux商店 (Redux Store)

Our Redux store will include reducers that handle the search and favorites actions. Additionally, we’ll need to include a status reducer to track state changes when a user initiates an action. We’ll explore more on the status reducer later.

我们的Redux商店将包括处理搜索和收藏夹操作的减速器。 另外,我们需要包括一个状态减少器,以在用户启动动作时跟踪状态变化。 稍后,我们将进一步探讨状态减少器。

//store.js
import { createStore, combineReducers, applyMiddleware } from 'redux';import thunk from "redux-thunk";
import search from './reducers/searchReducer';import favorites from './reducers/favoritesReducer';import status from './reducers/statusReducer';
export default createStore(  combineReducers({    search,    favorites,    status  }),  {},  applyMiddleware(thunk))

We’ll also apply the Redux Thunk middleware right away. We’ll go more into detail on that later. Now, let’s figure out how we manage the state changes when a user submits a search.

我们还将立即应用Redux Thunk中间件。 稍后我们将对此进行详细介绍。 现在,让我们弄清楚用户提交搜索时如何处理状态更改。

搜索减速器 (Search Reducer)

When a user performs a search action, we want to update the store with a new search result via searchReducer. We can then render our components accordingly. The general flow of events looks like this:

当用户执行搜索操作时,我们希望通过searchReducer用新的搜索结果更新商店。 然后,我们可以相应地渲染组件。 事件的一般流程如下所示:

We’ll treat “Get search result” as a black box for now. We’ll explore how that works later with Redux Thunk. Now, let’s implement the reducer function.

现在,我们将“获取搜索结果”视为黑匣子。 我们稍后将探讨Redux Thunk的工作原理。 现在,让我们实现reducer函数。

//searchReducer.js
const initialState = {  "title": "",  "year": "",  "plot": "",  "poster": "",  "imdbID": "",}
export default (state = initialState, action) => {  if (action.type === 'SEARCH_SUCCESS') {    state = action.result;  }  return state;}

The initialState will represent the data structure defined earlier as a single movie result object. In the reducer function, we handle the action where a search is successful. If the action is triggered, we simply reassign the state to the new movie result object.

initialState将表示先前定义为单个电影结果对象的数据结构。 在reducer函数中,我们处理搜索成功的动作。 如果触发了动作,我们只需将状态重新分配给新的电影结果对象。

//searchActions.jsexport const searchSuccess = (result) => ({  type: 'SEARCH_SUCCESS', result});

We define an action called searchSuccess that takes in a single argument, the movie result object, and returns an action object of type “SEARCH_SUCCESS”. We will dispatch this action upon a successful search API call.

我们定义了一个名为searchSuccess的动作,该动作采用单个参数(电影结果对象),并返回类型为“ SEARCH_SUCCESS ”的动作对象。 我们将在成功执行搜索API调用后调度此操作。

Let’s explore how the “Get search result” from earlier works. First, we need to make a remote API call to our backend API server. When the request receives a successful JSON response, we’ll dispatch the searchSuccess action along with the payload to searchReducer.

让我们探讨一下早期的“获取搜索结果”的工作方式。 首先,我们需要对后端API服务器进行远程API调用。 当请求接收到成功的JSON响应时,我们会将searchSuccess操作和有效负载一起分派到searchReducer

Knowing that we’ll need to dispatch after an asynchronous call completes, we’ll make use of Redux Thunk. Thunk comes into play for making multiple dispatches or delaying a dispatch. With Thunk, our updated flow of events looks like this:

知道我们需要在异步调用完成后分派,因此我们将使用Redux Thunk 。 Thunk可用于进行多个调度或延迟调度。 使用Thunk,我们更新的事件流如下所示:

For this, we define a function that takes in a single argument title and serves as the initial search action. This function is responsible for fetching the search result and dispatching a searchSuccess action:

为此,我们定义了一个函数,该函数采用单个参数title并用作初始搜索动作。 这个 函数负责获取搜索结果并调度searchSuccess操作:

//searchActions.jsimport apiClient from '../apiClient';
...
export function search(title) {  return (dispatch) => {    apiClient.query(title)      .then(response => {        dispatch(searchSuccess(response.data))      });  }}

We’ve set up our API client beforehand, and you can read more about how I set up the API client here. The apiClient.query method simply performs an AJAX GET request to our backend server and returns a Promise with the response data.

我们已经预先设置了API客户端,您可以在此处阅读有关我如何设置API客户端的更多信息apiClient.query方法仅向后端服务器执行AJAX GET请求,并返回带有响应数据的Promise。

We can then connect this function as an action dispatch to our SearchContainer component:

然后,我们可以将此功能作为动作分派连接到我们的SearchContainer组件:

//SearchContainer.js
import React from 'react';import { connect } from 'react-redux';import { search } from '../actions/searchActions';
...
const mapStateToProps = (state) => (  {    result: state.search,  });
const mapDispatchToProps = (dispatch) => (  {    search(title) {      dispatch(search(title))    },  });
export default connect(mapStateToProps, mapDispatchToProps)(SearchContainer);

When a search request succeeds, our SearchContainer component will render the movie result:

搜索请求成功后,我们的SearchContainer组件将呈现电影结果:

处理其他搜索状态 (Handling Other Search Statuses)

Now we have our search action working properly and connected to our SearchContainer component, we’d like to handle other cases other than a successful search.

现在,我们的搜索操作可以正常运行并连接到SearchContainer组件,我们希望处理成功搜索以外的其他情况。

搜索请求待处理 (Search request pending)

When a user submits a search, we’ll display a loading animation to indicate that the search request is pending:

当用户提交搜索时,我们将显示加载动画,以指示搜索请求正在等待处理:

搜索请求成功 (Search request succeeds)

If the search fails, we’ll display an appropriate error message to the user. This is useful to provide some context. A search failure could happen in cases where a movie title is not available, or our server is experiencing issues communicating with the OMDb API.

如果搜索失败,我们将向用户显示相应的错误消息。 这对于提供一些上下文很有用。 如果电影标题不可用,或者我们的服务器遇到与OMDb API通信的问题,则可能会导致搜索失败。

To handle different search statuses, we’ll need a way to store and update the current status along with any error messages.

为了处理不同的搜索状态,我们需要一种方法来存储和更新当前状态以及所有错误消息。

状态减少器 (Status Reducer)

The statusReducer is responsible for tracking state changes whenever a user performs an action. The current state of an action can be represented by one of the three “statuses”:

statusReducer负责在用户执行操作时跟踪状态更改。 动作的当前状态可以用以下三种“状态”之一表示:

  • Pending (when a user first initiates the action)

    待定(用户首次启动操作时)
  • Success (when a request returns a successful response)

    成功(请求返回成功响应时)
  • Error (when a request returns an error response)

    错误(请求返回错误响应时)

With these statuses in place, we can render different UIs based on the current status of a given action type. In this case, we’ll focus on tracking the status of the search action.

有了这些状态后,我们可以根据给定操作类型的当前状态来呈现不同的UI。 在这种情况下,我们将专注于跟踪搜索操作的状态。

We’ll start by implementing the statusReducer. For the initial state, we need to track the current search status and any errors:

我们将从实现statusReducer开始。 对于初始状态,我们需要跟踪当前的搜索状态和任何错误:

// statusReducer.jsconst initialState = {  search: '',      // status of the current search  searchError: '', // error message when a search fails}

Next, we need to define the reducer function. Whenever our SearchContainer dispatches a “SEARCH_[STATUS]” action, we will update the store by replacing the search and searchError properties.

接下来,我们需要定义化简函数。 每当我们的SearchContainer调度“ SEARCH_ [STATUS]”操作时,我们将通过替换searchsearchError属性来更新商店。

// statusReducer.js
...
export default (state = initialState, action) => {  const actionHandlers = {    'SEARCH_REQUEST': {      search: 'PENDING',      searchError: '',    },    'SEARCH_SUCCESS': {      search: 'SUCCESS',       searchError: '',          },    'SEARCH_FAILURE': {      search: 'ERROR',      searchError: action.error,     },  }  const propsToUpdate = actionHandlers[action.type];  state = Object.assign({}, state, propsToUpdate);  return state;}

We use an actionHandlers hash table here since we are only replacing the state’s properties. Furthermore, it improves readability more than using if/else or case statements.

我们在这里使用一个actionHandlers哈希表,因为我们仅替换状态的属性。 此外,与使用if/elsecase语句相比,它提高了可读性。

With our statusReducer in place, we can render the UI based on different search statuses. We will update our flow of events to this:

有了我们的statusReducer ,我们可以根据不同的搜索状态呈现UI。 我们将事件流程更新为:

We now have additional searchRequest and searchFailure actions available to dispatch to the store:

现在,我们还有其他searchRequestsearchFailure操作可用于调度到商店:

//searchActions.js
export const searchRequest = () => ({  type: 'SEARCH_REQUEST'});
export const searchFailure = (error) => ({  type: 'SEARCH_FAILURE', error});

To update our search action, we will dispatch searchRequest immediately and will dispatch searchSuccess or searchFailure based on the eventual success or failure of the Promise returned by Axios:

为了更新我们的搜索操作,我们将立即调度searchRequest ,并将根据Axios返回的Promise的最终成功或失败来调度searchSuccesssearchFailure

//searchActions.js
...
export function search(title) {  return (dispatch) => {    dispatch(searchRequest());
apiClient.query(title)      .then(response => {        dispatch(searchSuccess(response.data))      })      .catch(error => {        dispatch(searchFailure(error.response.data))      });  }}

We can now connect the search status state to our SearchContainer, passing it as a prop. Whenever our store receives the state changes, our SearchContainer renders a loading animation, an error message, or the search result:

现在,我们可以将搜索状态状态连接到我们的SearchContainer ,并将其作为道具传递。 每当我们的商店收到状态更改时,我们的SearchContainer就会呈现加载动画,错误消息或搜索结果:

//SearchContainer.js
...(imports omitted)
const SearchContainer = (props) => (  <main id='search-container'>    <SearchInputForm       placeholder='Search movie title...'      onSubmit={ (title) => props.search(title) }    />    {      (props.searchStatus === 'SUCCESS')      ? <MovieItem          movie={ props.result }          ...(other props)        />      : null    }    {      (props.searchStatus === 'PENDING')      ? <section className='loading'>          <img src='../../images/loading.gif' />        </section>      : null    }    {      (props.searchStatus === 'ERROR')      ? <section className='error'>           <p className='error'>            <i className="red exclamation triangle icon"></i>            { props.searchError }          </p>        </section>      : null    }  </main>);
const mapStateToProps = (state) => (  {    searchStatus: state.status.search,    searchError: state.status.searchError,    result: state.search,  });
...

收藏夹减速器 (Favorites Reducer)

We’ll need to handle CRUD actions performed by a user on the favorites list. Recalling from our API endpoints earlier, we’d like to allow users to perform the following actions and update our store accordingly:

我们需要处理用户在“收藏夹”列表上执行的CRUD操作。 从我们的API端点回忆起,我们希望允许用户执行以下操作并相应地更新我们的商店:

  • Save a movie into the favorites list

    将电影保存到收藏夹列表中
  • Retrieve all favorited movies

    检索所有喜欢的电影
  • Update a favorite’s rating

    更新收藏的评分
  • Delete a movie from the favorites list

    从收藏夹列表中删除电影

To ensure that the reducer function is pure, we simply copy the old state into a new object together with any new properties usingObject.assign. Note that we only handle actions with types of _SUCCESS:

为了确保reducer函数是纯函数,我们只需使用Object.assign将旧状态与任何新属性一起复制到新对象中。 请注意,我们只处理_SUCCESS类型的动作

//favoritesReducer.js
export default (state = {}, action) => {  switch (action.type) {    case 'SAVE_FAVORITE_SUCCESS':      state = Object.assign({}, state, action.favorite);      break;
case 'GET_FAVORITES_SUCCESS':      state = action.favorites;      break;
case 'UPDATE_RATING_SUCCESS':      state = Object.assign({}, state, action.favorite);      break;
case 'DELETE_FAVORITE_SUCCESS':      state = Object.assign({}, state);      delete state[action.imdbID];      break;
default: return state;  }  return state;}

We’ll leave the initialState as an empty object. The reason is that if our initialState contains placeholder movie items, our app will render them immediately before waiting for the actual favorites list response from our backend API server.

我们将initialState保留为空对象。 原因是,如果我们的initialState包含占位符电影项目,那么我们的应用将在等待来自后端API服务器的实际收藏夹列表响应之前立即渲染它们。

From now on, each of the favorites action will follow a general flow of events illustrated below. The pattern is similar to the search action in the previous section, except right now we’ll skip handling any “PENDING” status.

从现在开始,每个收藏夹操作都将遵循以下所示的一般事件流。 该模式与上一节中的搜索操作相似,除了现在我们将跳过处理任何“ PENDING”状态。

保存收藏夹动作 (Save Favorites Action)

Take the save favorites action for example. The function makes an API call to with our apiClient and dispatches either a saveFavoriteSuccess or a saveFavoriteFailure action, depending on whether or not we receive a successful response:

以保存收藏夹操作为例。 该函数使用我们的apiClient进行API调用,并根据我们是否收到成功的响应,调度saveFavoriteSuccesssaveFavoriteFailure操作:

//favoritesActions.jsimport apiClient from '../apiClient';
export const saveFavoriteSuccess = (favorite) => ({  type: 'SAVE_FAVORITE_SUCCESS', favorite});
export const saveFavoriteFailure = (error) => ({  type: 'SAVE_FAVORITE_FAILURE', error});
export function save(movie) {  return (dispatch) => {    apiClient.saveFavorite(movie)      .then(res => {        dispatch(saveFavoriteSuccess(res.data))      })      .catch(err => {        dispatch(saveFavoriteFailure(err.response.data))      });  }}

We can now connect the save favorite action to AddFavoriteForm through React Redux.

现在,我们可以节省通过阵营终极版最喜欢的动作AddFavoriteForm连接。

To read more about how I handled the flow to display flash messages, click here.

要了解有关如何处理显示Flash消息的更多信息, 请单击此处

结论 (Conclusion)

Designing the frontend of an application requires some forethought, even when using a popular JavaScript library such as React. By thinking about how the data structures, components, APIs, and state management work as a whole, we can better anticipate edge cases and effectively fix errors when they arise. By using certain design patterns such as controlled components, Redux, and handling AJAX workflow using Thunk, we can streamline managing the flow of providing UI feedback to user actions. Ultimately, how we approach the design will have an impact on usability, clarity, and future scalability.

即使使用流行JavaScript库(例如React),设计应用程序的前端也需要一些周全的考虑。 通过考虑数据结构,组件,API和状态管理作为一个整体的工作方式,我们可以更好地预测边缘情况并在出现错误时进行有效修复。 通过使用某些设计模式,例如受控组件,Redux,以及使用Thunk处理AJAX工作流程,我们可以简化对向用户操作提供UI反馈的流程的管理。 最终,我们如何进行设计将对可用性,清晰度和未来可扩展性产生影响。

参考文献 (References)

Fullstack React: The Complete Guide to ReactJS and Friends

Fullstack React:ReactJS和Friends完整指南

关于我 (About me)

I am a software engineer located in NYC and co-creator of SpaceCraft. I have experience in designing single-page applications, synchronizing state between multiple clients, and deploying scalable applications with Docker.

我是位于纽约市的软件工程师,也是SpaceCraft的共同创建者。 我在设计单页应用程序,在多个客户端之间同步状态以及使用Docker部署可伸缩应用程序方面有丰富的经验。

I am currently looking for my next full-time opportunity! Please get in touch if you think that I will be a good fit for your team.

我目前正在寻找下一个全职机会! 取得联系 ,如果你认为我会是一个很好的适合你的团队。

翻译自: https://www.freecodecamp.org/news/how-i-architected-a-single-page-react-application-3ebd90f59087/

react单页应用权限控制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值