从标签时代到富客户端:从Web 1.0到Flex

从标签时代到富客户端:从Web 1.0到Flex

作者 James Ward & Shashank Tiwari译者 韩锴 发布于 2008年3月6日 下午8时4分

社区
Java
主题
RIA,
Web 2.0,
Web框架

简介

iPhone的成功明白无误地说明了一个事实:用户希望在他们的软件体验中感受到更多的交互。更多的交互可以让用户更好地利用程序的特性,提高他们的效率。这就是为什么“交互”不仅仅在个人信息管理程序中、而且在企业级业务程序中也是非常关键的。良好交互性的最大受益者是各种涉及到数据可视化的企业应用程序,因为更高的效率直接转化为了更好的决策,也立即会变为商业利润。 Dashboard是最典型的数据可视化应用程序。最具讽刺意味的是,今天大多数Dashboard在创建高效的用户体验的过程中,缺少交互性。因此我们决定一点点地美化一个典型的Web 1.0的Dashboard,给它增加更多的交互和丰富的功能。我们不会从头开始创建完整的应用程序,这未免是在重复发明车轮。相反,我们会重新设计界面,并把它整合到现有的服务器端架构中。通过这次学习,我们会完成一个简单但有意义的转 换。
相关厂商内容

通过Oracle SQL、Linux和Ruby解决与数据集相关的问题

基于浪潮Loushang统一平台的电力行业解决方案

《IDC:SOA中国路线图》技术分析报告下载

我们在练习中使用的Dashboard是开源的Pentaho BI套件的一部分。数据和视图来自于Pentaho BI发行 版中的示例应用程序。

尽管我们的示例程序是一个Dashboard应用程序,但是其中的概念可以用于任何需要从Web 1.0迁移到 RIA的项目中。我们选用的RIA工具集是Adobe Flex。我们在此讨论的内容,全部是基于Flex框架、Flash VM以及相关支持库的。

如果你想亲自动手完成本文介绍的内容,应该安装下面的软件:

* 免费的Flex SDK, Flash Player 9 (安装在浏览器中)以及Flex Builder 3 Public Beta 2(可选的)
* Pentaho 1.6 Demo Bundle


记住首先要启动Pentaho服务器、登录到Dashboard,然后才能运行Flex界面。Flex界面假设你已经登录并通过验证了。源代码还假设服务器会监听8080端口、等待HTTP请求。如果你的Pentaho HTTP服务器需要监听其他的端口,请修改源代码。为了方便那些希望跳过安装Pentaho服务器这步骤的人,下载的 Pentaho源代码绑定包中还包含一个伪数据集版本的Flex界面。

现在已经万事俱备了,下面可以仔细研究我们的示例程序了。
看看我的Pentaho Dashboard

首先是免责声明——这篇文章能够仅仅提供了一点浅尝辄止的体验。尽管不是必须的,不 过如果你希望在深入学习本文前先了解一些背景知识的话,可以到learn.adobe.com或者flex.org去看看。这篇文章也没有打算讲述创建可维护的代码构架的最佳 实践,或者用户界面的设计实践。比如,尽管使用Ely Greenfield的Chart drill down组件 [demo]可以改进用户体验,但是它 也会让你付出更多的努力才能设置并运行示例程序,因此我们在示例中并没有选用它。我们希望为那些只想简单了解一下代码的人,提供一种单纯的复制与粘贴的体验。如果混入了第三方的组件或者一个实实在在的MVC架构,将会令这个体验变得复杂。如果你想进一步深入挖掘这些主题,可以在网上或者Adobe Developer Connection上找到大量的相关文章。
为了让你能够对我们即将创建的东西有一个了解,我们先来看看最终 的效果。查看Demo。



而Pentaho Dashboard最初的样子却是这样的:

正如前文提到的,我们使用Flex创建新的Dashboard。这个基于Flex的Dashboard是用声明式的MXML语言和过程式的ActionScript语言编写的,并利用免费的Flex SDK编译为SWF文件。SWF文件是Flash Player VM上的字节码。你可以在浏览器中运行SWF文件,或者通过Adobe整合运行时(AIR)将它转化为一个桌面应用程序。让我们先来看看创建新的Dashboard的源代码。

<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" creationComplete="initApp()" layout="horizontal">
<mx:Script>
<![CDATA[
// import classes used in ActionScript and not used in the MXML
// ActionScript is code generated from MXML by the free Flex SDK's mxmlc compiler
// The generated ActionScript can be viewed if you pass mxmlc the - compiler.keep-generated-actionscript argument
import mx.charts.HitData;
import mx.collections.ArrayCollection;
import mx.rpc.events.ResultEvent;

// Bindable is a metadata / annotation which generates event code so that the variable
// can be used in an MXML Binding Expression, ie. dataProvider="{territoryRevenue} "
[Bindable] public var territoryRevenue:ArrayCollection;
[Bindable] public var productlineRevenue:Object;
[Bindable] public var topTenCustomersRevenue:Object;

// Variables in ActionScript have namespaces like in Java. You can use the typical public,
// private, and protected namespaces as well as create your own namespaces
// Object types are specified with a colon character and are optional but recommended.
private var _selectedTerritory:String = "*";
private var _selectedProductLine:String = "*";

// the initApp function is called when the Application's creationComplete event is fired (see the mx:Application tag above
private function initApp():void
{
// initializes our data caches
productlineRevenue = new Object();
topTenCustomersRevenue = new Object();

// initiates the request to the server to get the Sales by Territory data
// tSrv is defined in MXML below. It could also have been defined in ActionScript but would have been slightly more verbose tSrv.send();

// Since the Sales by Product Line and Top Ten Customer Sales depend on the selected territory
// we make a call to the functions that will fetch the data based on the selected territory (or pull it from cache)
// in this case the selected territory is "*" or our indicator for all. When the users selects a new territory
// these methods will be called again but the selected territory will be different
updateProductLineRevenue();
updateTopTenCustomersRevenue();
}

// Setter method that causes the data stores for the Sales by Product Line and Top Ten Customer Sales
// to be updated and the charts to be updated accordingly
public function set selectedTerritory (territory:String):void
{
// update the private backing variable
_selectedTerritory = territory;

updateProductLineRevenue();
updateTopTenCustomersRevenue();
}

// Getter method that returns the selected Territory
// This method has the Bindable metadata / annotation on it so that the selectedTerritory property can
// be used in a binding expression
[Bindable] public function get selectedTerritory():String
{
return _selectedTerritory;
}

// Setter method similar to selectedTerritory but for the selected product line
public function set selectedProductLine(productLine:String):void
{
_selectedProductLine = productLine;

updateTopTenCustomersRevenue();
}

[Bindable] public function get selectedProductLine():String
{
return _selectedProductLine;
}

// If the data is in cache then just directly update the chart based on the selected territory.
// If the data is not in cache then assemble the name value pairs that are needed by the
// web service request, then make the request.
private function updateProductLineRevenue():void
{
if (productlineRevenue[_selectedTerritory] == undefined)
{
productlineRevenue [_selectedTerritory] = new ArrayCollection();

var p:Object = new Object();

if (_selectedTerritory != "*")
{
p.territory = _selectedTerritory;
}

plSrv.send(p);
}
else
{
plPie.dataProvider = productlineRevenue[_selectedTerritory];
}
}

// Similar to updateProductLineRevenue except that both the selected territory and
// the selected product line determine the data set.
private function updateTopTenCustomersRevenue():void
{
if (topTenCustomersRevenue [_selectedTerritory + '_' + _selectedProductLine] == undefined)
{
topTenCustomersRevenue[_selectedTerritory + '_' + _selectedProductLine] = new ArrayCollection();

var p:Object = new Object();

if (_selectedTerritory != "*")
{
p.territory = _selectedTerritory;
}

if (_selectedProductLine != "*")
{
p.productline = _selectedProductLine;
}

ttcSrv.send(p);
}
else
{
ttcBar.dataProvider = topTenCustomersRevenue[_selectedTerritory + '_' + _selectedProductLine];
}
}

// This function handles a response from the server to get the Sales by territory. It reorganizes that data
// into a format that the chart wants it in. The tPie chart notices changes to the underlying ArrayCollection
// that happen inside the for each loop. When it sees changes it updates it's view of the data.
private function handleTResult (event:ResultEvent):void
{
territoryRevenue = new ArrayCollection();
tPie.dataProvider = territoryRevenue;

var hdr:ArrayCollection = event.result.Envelope.Body.ExecuteActivityResponse.swresult['COLUMN-HDR-ROW']['COLUMN-HDR- ITEM'];
for each (var pl:Object in event.result.Envelope.Body.ExecuteActivityResponse.swresult['DATA-ROW'])
{
var spl:Object = new Object();
spl[hdr[0]] = pl['DATA-ITEM'][0];
spl[hdr[1]] = pl ['DATA-ITEM'][1];
territoryRevenue.addItem(spl);
}
}

// Similar to handleTResult except that it handles the data for Sales by Product Line
private function handlePLResult(event:ResultEvent):void
{
var hdr:ArrayCollection = event.result.Envelope.Body.ExecuteActivityResponse.swresult['COLUMN- HDR-ROW']['COLUMN-HDR-ITEM'];

for each (var pl:Object in event.result.Envelope.Body.ExecuteActivityResponse.swresult['DATA-ROW'])
{
var spl:Object = new Object();
spl[hdr[0]] = pl['DATA-ITEM'][0];
spl[hdr[1]] = pl ['DATA-ITEM'][1];
productlineRevenue[_selectedTerritory].addItem(spl);
}

plPie.dataProvider = productlineRevenue[_selectedTerritory];
}

// Similar to handleTResult except that it handles the data for Top Ten Customer Sales
private function handleTTCResult(event:ResultEvent):void
{
var hdr:ArrayCollection = event.result.Envelope.Body.ExecuteActivityResponse.swresult['ROW-HDR- ROW'];
var pl:ArrayCollection = event.result.Envelope.Body.ExecuteActivityResponse.swresult['DATA-ROW'];

for (var i:int = 0; i < pl.length; i++)
{
var spl:Object = new Object();
spl.name = hdr[i]['ROW-HDR-ITEM'][0];
spl.sales = pl[i]['DATA-ITEM'];
topTenCustomersRevenue[_selectedTerritory + '_' + _selectedProductLine].addItemAt(spl,0);
}

ttcBar.dataProvider = topTenCustomersRevenue[_selectedTerritory + '_' + _selectedProductLine];
}

// This function is called to format the dataToolTips on the tPie chart.
private function formatTPieDataTip (hitdata:HitData):String
{
return "<b>" + hitdata.item.TERRITORY + "</b><br>" + cf.format(hitdata.item.SOLD_PRICE);
}
]] >
</mx:Script>

<!-- These HTTP Services communicate via HTTP to a server -->
<mx:HTTPService id="tSrv" url="http://localhost:8080/pentaho/ServiceAction?solution=samples&path=steel- wheels/homeDashboard&action=Sales_by_Territory.xaction" result="handleTResult (event)"/>
<mx:HTTPService id="plSrv" url="http://localhost:8080/pentaho/ServiceAction?solution=samples&path=steel- wheels/homeDashboard&action=Sales_by_Productline.xaction" result="handlePLResult(event)"/>
<mx:HTTPService id="ttcSrv" url="http://localhost:8080/pentaho/ServiceAction? solution=samples&path=steel-wheels/homeDashboard&action=topnmdxquery.xaction" result="handleTTCResult(event)"/>
<!-- Non-visual component to format currency's correctly. Used in the formatTPieDataTip function -->
<mx:CurrencyFormatter id="cf" precision="0"/>

<!-- Effects used to make the charts more interactive -->
<mx:SeriesInterpolate id="plEffect"/>
<mx:SeriesSlide id="ttcSlide" direction="right"/>

<!-- Stacked vertical layout container -- >
<mx:VBox height="100%" width="40%">
<!-- Nice box with optional drop shadows, title bars, and control bars -->
<mx:Panel width="100%" height="100%" title="Revenue By Territory">
<!-- Pie Chart -->
<mx:PieChart id="tPie" width="100%" height="100%" showDataTips="true" dataTipFunction="formatTPieDataTip">
<!-- Sets the itemClick property on the PieChart to the embedded ActionScript code. We could have also called a function defined above. -->
<mx:itemClick>
// calls the appropriate setter method
selectedTerritory = event.hitData.item.TERRITORY;

// tells the pie chart to explode the pie wedge the user click on
var explodeData:Array = [];
explodeData[territoryRevenue.getItemIndex(event.hitData.item)] = 0.15;
tPie.series [0].perWedgeExplodeRadius = explodeData;
</mx:itemClick>
<!-- Sets the series property on the Pie Chart. -->
<mx:series>
<!-- The Pie Series defines how the Pie Chart displays it's data. -->
<mx:PieSeries nameField="TERRITORY" field="SOLD_PRICE" labelPosition="insideWithCallout" labelField="TERRITORY"/>
</mx:series>
</mx:PieChart>
</mx:Panel>

<!-- A Binding Expression in the title bar of the Panel uses the Bindable getter for the selectedTerritory property -->
<mx:Panel width="100%" height="100%" title="Revenue By Product Line (Territory = {selectedTerritory})">
<mx:PieChart id="plPie" width="100% " height="100%" showDataTips="true">
<mx:itemClick>
selectedProductLine = event.hitData.item.PRODUCTLINE;

var explodeData:Array = [];
explodeData[productlineRevenue [_selectedTerritory].getItemIndex(event.hitData.item)] = 0.15;
plPie.series [0].perWedgeExplodeRadius = explodeData;
</mx:itemClick>
<mx:series>
<!-- The showDataEffect on the Series uses Binding to (re)use an effect defined above -->
<mx:PieSeries nameField="PRODUCTLINE" field="REVENUE" labelPosition="insideWithCallout" showDataEffect=" {plEffect}" labelField="PRODUCTLINE"/>
</mx:series>
</mx:PieChart>
</mx:Panel>
</mx:VBox>
<mx:Panel width="100%" height="100%" title="Top 10 Customers (Territory = {selectedTerritory} | Product Line = {selectedProductLine})">
<mx:BarChart id="ttcBar" width="100%" height="100%" showDataTips="true">
<mx:series>
<mx:BarSeries xField="sales" showDataEffect="{ttcSlide}"/>
</mx:series>
<mx:verticalAxis>
<mx:CategoryAxis categoryField="name"/>
</mx:verticalAxis>
</mx:BarChart>
</mx:Panel>

</mx:Application>

下载并查看源代码。注意应用程序有两个版本。一个是使用伪数据集的 pentaho_dashboard_demo.mxml,另一个是会连接到真实的Pentaho服务器以获取数据的 pentaho_dashboard.mxml。
迁移过程

向RIA迁移的过程的第一步是设计一个新界面。同时你还需要明确如何向应用程序暴露出数据和服务。 Pentaho已经为我们暴露出了数据和服务,所以留下来最难的部分是把数据解析为Flex Charting组件所期望的格式。最终你可以将后端的服务与新的界面挂接到一起。让我们来深入每一步的细节。
设计新的接口

创建新RIA接口的最佳思考方式是什么?不是考虑如何使现有的接口更丰富,而是要以用户希望如何查看以及与数据交互为出发点,重新进行思考。在这个过程中,设计师会发挥很大的作用。如果可能,尽可能地发挥他们的创造性。作为开发者,你可以先创建一个原型。为了提高效率,你需要创建一个模拟数据集。你可以在Sales_by_Productline.xaction、Sales_by_Territory.xaction和 topnmdxquery.xaction文 件中找到我们使用的数据集。在例子中,我把模拟数据集做成与Pentaho暴露出来的Web Services很相似 。尽管这不是必须的,但它简化了挂接到真正的服务上所需的工作。 有了模拟数据集,我们可以在设计 上进行更快的迭代了。

我们所做的设计看上去与原来的Dashboard几乎一样。如果有一位设计师帮助我们(实际上并没有),我们可以拿出更有创造性的产品。尽管如此,我们这些开发者还是能够创建出有更多交互的Dashboard。我们其实可以设计出更复杂的界面来,但是在这个例子中,还是希望保持代码易于阅读和理解。

创建UI的代码相当直接。下面的MXML代码就能创建一个PieChart:

<mx:PieChart width="100%" height="100%">
<mx:series>
<mx:PieSeries nameField="TERRITORY" field="SOLD_PRICE" labelPosition="insideWithCallout" labelField="TERRITORY"/>
</mx:series>
</mx:PieChart>

为了增加交互性,你可以添加一个itemClick事件处理器:

<mx:itemClick>
selectedTerritory = event.hitData.item.TERRITORY;

var explodeData:Array = [];
explodeData[territoryRevenue.getItemIndex (event.hitData.item)] = 0.15;
tPie.series[0].perWedgeExplodeRadius = explodeData;
</mx:itemClick>

当我们设置selectedTerritory时(就像在itemClick事件处理器中所做的),会引起一些其他的动作 。这个例子中,我们希望刷新数据集,这个数据集与相应的饼图绑定到一起了:

public function set selectedTerritory(territory:String):void
{
_selectedTerritory = territory;

updateProductLineRevenue();
updateTopTenCustomersRevenue();
}

我们也可以很简单地为数据集已改变的Chart增加一个平滑的转化。第一件要做到事情就是添加一个我 们打算使用的效果的实例

<mx:SeriesInterpolate id="plEffect"/>

我们还需要把showDataEffect绑定到效果的实例上,这样,当数据发生变化后,就可以通知饼状图使 用这个效果了

<mx:PieSeries nameField="PRODUCTLINE" field="REVENUE" labelPosition="insideWithCallout" showDataEffect="{plEffect}" labelField="PRODUCTLINE"/>

我们也可能想增加一个鼠标悬浮时的提示:

<mx:PieChart id="tPie" width="100%" height="100%" showDataTips="true" dataTipFunction="formatTPieDataTip">

dataTipFunction被命名为formatTPieDataTip,后者返回一个自定义的字符串:

private function formatTPieDataTip(hitdata:HitData):String
{
return "<b>" + hitdata.item.TERRITORY + "<b><br>" + cf.format(hitdata.item.SOLD_PRICE);
}

我们可以指定很多其他的风格来自定义应用程序的感观(look and feel)。开发者可以将风格内联在 属性中,也可以定义在外部的CSS文件中。
暴露后端

使用Pentaho,我们可以很容易地发现Web Service并与之交互。Web Service给我们提供了用于 Dashboard的数据。可以返回按照区域分布的销售版图的URL是(在本地运行时): 这里。

有些URL会携带参数,来确定返回什么数据集。例如,“十大客户”的Web Service可以带 有两个参数,区域和产品类型,它们共同决定了应该返回哪些信息。
将前端挂接到后端

要想把前端挂接到后端,你首先要创建一个HTTPService(如果使用的是SOAP,则创建WebService):

<mx:HTTPService id="tSrv" url="http://localhost:8080/pentaho/ServiceAction?solution=
samples&path=steel -wheels/homeDashboard&action=Sales_by_Territory.xaction"
result="handleTResult(event)"/>

result属性指定了响应从服务器返回后要执行的代码。在这个例子中,我们只要调用一个名为 handleTResult的方法,并传递一个Event类型的参数就可以了。这个方法负责把我们接收到的数据转换为 Charts所需的样式。无论我们在使用Charts还是其他诸如DataGrid之类的组件,都需要一些简单的处理。在这个例子中,我们要对数据做一些处理然后再把它交给Chart。

private function handleTResult(event:ResultEvent):void
{
territoryRevenue = new ArrayCollection();
tPie.dataProvider = territoryRevenue;

var hdr:ArrayCollection = event.result.Envelope.Body.ExecuteActivityResponse.swresult['COLUMN- HDR-ROW']['COLUMN-HDR-ITEM'];

for each (var pl:Object in event.result.Envelope.Body.ExecuteActivityResponse.swresult['DATA-ROW'])
{
var spl:Object = new Object();
spl[hdr[0]] = pl['DATA-ITEM'][0];
spl[hdr[1]] = pl['DATA-ITEM'][1];\\\\\\\\
territoryRevenue.addItem(spl);
}
}

在这个例子里,所有的代码都放到一个文件中了。在实际的项目中,我们会按照模型、视图和控制器的架构将它拆分到不同的文件中。我们在其他的时间里详细讨论如何使用正确的设计模式来构建大型的应用程序。至此,我们就完成了这个示范的应用程序。从现在开始到本文结束,我们将通过Adobe Flex讨论 RIA的一些有趣的方面。
这个示例的精彩之处为何?

这个简单的示例证明了对于传统的Web应用程序来说,丰富的、带有更多交互的用户界面是多么重要。这个程序的美妙之处,是它为现有的程序提供一个很棒的界面,却不需要进行大量的重构。这种便利一部分要归因于Pentaho暴露出了它的服务器端调用,另一部分归因于Flex能够平稳地消化掉这些服务的能力 ,这得利于Flex的组件和数据绑定模型。

这里示例同样示范了如何谨慎地选择、匹配各种技术。一个优秀的、健壮的Java服务器程序可以有效 地被挂接到一个富Flex界面,从而创造出令人眩目的程序。这是一个很有前景的方向,这个示例希望能将 这种理念带入现实。

应用程序的另一方面是,我们通过真实的、可运行的代码能够看到,使用ActionScript和MXML构建 Flex应用程序是多么容易。对于大多数熟悉Java和XML语法的人来说,ActionScript和MXML看起来是相当 亲切的。不是吗?
现在还差什么?如何弥补这一缺口?

这个示例并不是产品级的软件。它更关注于一个简单的原型。但是,如果花些时间将Pentaho Web Service调用抽象为一个ActionScript库,这可能是迈向健壮程序的第一步。有一些开源项目已经开始着手构建这样的库了。示例程序也在向这个方向靠拢,并且使用了很少的一些面向对象的构造。应用MVC架构模式,面向接口编程,恰当地使用继承和多态,就能创建出可维护的、干净的应用程序。在示例程序中,我们只是简单复制了旧程序的外观。在理想的情况下,我们应该根据交互性需求创建出全新的界面,然 后将可视化组件和Web Service组件绑定到一起。为了创建出满足生产环境要求的应用程序,我们应该投 入时间和精力。
进一步阅读

这个示例仅仅是富互联网应用程序的冰山一角。 比如我们可做的改进之一就是把图表做成可以逐层深 入(drill down)细节的,并加上更有意义的过渡效果。Ely Greenfield建了一个很漂亮的演示,他详细 说明了如何用Flex Charts实现drill down效果,请移步他的博客 。我们使用的是Flex 2构建示例程序,不过在Flex 3中,Chart 组件还包含了一些新的特性。你可以在这里或者这里读到更多的信息。如果你想学习Flex编程,可以查看 flex.org和learn.adobe.com 。
关于作者

James Ward是Adobe公司的Flex技术布道者,也是Adobe的JCP代表,负责JSR 286、299和301。就像他最爱的爬山一样,他也热爱编程,认为它提供了无穷尽的新发现。他在爬山的过程中到过很多地方。同样,技术也带给他很多冒险经历,其中包括:九十年代早期是pascal和汇编;九十年代中期是Perl、HTML和 JavaScript;Java及其大量框架都是始于九十年代后期。现在他的主要工作是利用Flex构建好看的前端,而后端还是基于Java。在来到Adobe之前,他曾经创建过一个服务门户。你可以在他的博客上了解更多信 息:www.jamesward.org。

Shashank Tiwari是Saven Technologies首席技术官。Saven Techologires位于芝加哥,为银行和金融服务公司提供服务。 Shashank是一位经验丰富的开发者、作家和演说家,在是JSR 274, 283, 299, 301和312专家组的成员。他对大量语言都感兴趣,包括Java、ActionScript、Ptyhon、Perl、PHP、G++、Groovy、JavaScript、 Ruby和Matlab。他也是OReilly网络的知名作者。最近他在忙于使用Flex和Java构建Web 2.0应用程序。更 多信息可以访问:www.shanky.org。

查看英文原文:[url=http://www.infoq.com/articles/web-flex-port;jsessionid=5ED636F8B16A6F6887AEE53E54E888E7]From Tags to Riches: Going from Web 1.0 to Flex[/url]
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值