教程– Griffon:使用Groovy构建桌面应用程序

如果您愿意将Groovy融入其中,那么构建桌面应用程序将是一种愉快的体验。 Griffon是一个遵循Grails精神的应用程序框架,将乐趣带回了桌面开发。

桌面应用程序开发 ,这是当今Web开发,并发性和并行性都很少听到的术语。 但是,这并不意味着它已经死了。 实际上,在某些行业中,桌面应用程序是解决特定问题的最佳选择。 在某些其他环境中,出于安全原因,这是唯一的选择。 想想金融机构,银行,卫生行业,生物研究,化学实验室,卫星运营和军事; 仅举几个。 所有这些都施加了一组特定的限制,其中桌面应用程序优于Web应用程序,例如安全性,对本地资源的访问,设备和端口通信。 他们的共同点是格里芬。 是的,格里芬框架已帮助所有这些行业和领域的团队完成工作。

您可能以前听说过格里芬,但仍然想知道它是什么。 简而言之,它是JVM的桌面应用程序平台。 由于该项目是Groovy Swing团队的智囊团:Danno Ferrin,James Williams和我本人,因此它在Groovy社区中具有很深的渊源。 话虽如此,如果我对Griffon的某些功能感到有些兴奋,那么您可以原谅我,因为我非常喜欢该项目。 框架设计背后的主要推动力是Java开发人员应该很容易理解它。 它还应能使编码周期更快,同时保持源代码整洁。 最后,必须立即意识到生产率的提高和乐趣因素。

由于这些原因,团队决定遵循Grails框架及其社区的步骤。 这两个框架之间有很多相似之处。 例如,两者都具有命令行界面,可帮助您完成创建,构建,打包和部署应用程序的常规任务。 这两个框架都将Groovy语言用作各自软件堆栈的粘合剂。 该工具的集成也相当不错,因为主要的IDE和流行的文本编辑器为处理此类项目提供了良好的支持。

但是足够的理论知识,让我们开始实践吧! 本文的其余部分将致力于构建一个简单的通讯簿应用程序。 我们绝对不会在剩下的几页中构建完整的应用程序,但我希望所有要讨论的内容将为您提供足够的指导,以使您着手使用框架。

第一步是在计算机上下载并配置Griffon。 这样做有多种选择。 如果从下载页面中选择通用安装程序,它将解压缩二进制文件并为您配置路径环境,尤其是在Windows平台上。 或者,如果您使用的是Linux计算机,则可以尝试使用RPM或基于Debian的软件包。 ZIP或TGZ包可能是您的最后选择。 只需下载该软件包,然后将其解压缩到您选择的目录中即可-最好是没有空格的目录。 接下来,配置环境变量 GRIFFON_HOME ,该 环境变量 指向解压缩Griffon二进制发行版的目录。 最后,确保PATH环境变量包含对 GRIFFON_HOME / bin 的引用 如果一切顺利,在打开–version标志的情况下调用griffon命令应显示类似以下内容的输出

$ griffon –版本

————————————————

狮riff 0.9.5

————————————————

版本:2012年3月15日下午12:56

Groovy:1.8.6

蚂蚁:1.8.2

Slf4j:1.6.4

Spring:3.1.0。发布

JVM:1.6.0_29(Apple Inc.20.4-b02-402)

作业系统:Mac OS X 10.6.8 x86_64

好的。 是时候开始做生意了……

初始步骤

首先,我们如何创建应用程序? 通常,您可以选择基于Maven的方法并选择适当的原型来引导项目。 或者,您仅可以简单地创建一个新目录,获取一些Ant脚本并使用它来完成。 或者让您可信赖的IDE做出决定。 选择,选择,选择。 griffon命令行工具可以为您提供帮助。 通过调用以下命令,每个Griffon应用程序都以相同的方式启动

$ griffon创建应用程序通讯录

$ cd通讯录

您会在输出中看到一连串的行。 如果需要,请继续检查新创建的应用程序的内容。 create-app命令通过创建几个目录和一些文件来初始化应用程序。 这些目录之一特别重要,其名称为griffon-app。 在此目录中,您将找到另一组目录,这些目录有助于保持源代码的有条理。 图1 显示了刚才创建的griffon-app目录的扩展内容。

如您所知,Griffon利用MVC模式来排列组成应用程序的元素。 创建应用程序后,您还将获得一个初始MVC组,其名称与应用程序的名称匹配。 每个MVC成员中都有足够的代码来使应用程序运行。 是的,信不信由你,该应用程序已准备好启动。 返回控制台并执行以下命令

$ griffon运行应用程序

这应该编译应用程序源,程序包资源,汇总依赖关系并启动应用程序。 在几秒钟内,您应该会看到一个弹出的窗口, 如图2 所示

当然,它并没有太大的用处,但是我们还没有编写任何代码! 清单1显示了在打开文件 griffon-app / views / addressbook / AddressbookView.groovy 时可以找到的内容。

 
package addressbook
application(title: 'addressbook',
  preferredSize: [320, 240],
  pack: true,
  //location: [50,50], 
  locationByPlatform:true,
  iconImage: imageIcon('/griffon-icon-48x48.png').image,
  iconImages: [imageIcon('/griffon-icon-48x48.png').image,
               imageIcon('/griffon-icon-32x32.png').image,
               imageIcon('/griffon-icon-16x16.png').image]) {
    // add content here
    label('Content Goes Here') // delete me
}

我们在这里看到的是基于流行的Groovy功能:构建器的基于Swing的域特定语言(简称DSL)。 在我们的特殊情况下,我们正在处理SwingBuilder。 构建器不过是知道如何构建层次结构的节点和规则的集合。 巧合的是,Swing UI由组件树组成。 在“视图”中,我们可以观察到名为“应用程序”的顶级节点,以及一些应用于其的属性。 接下来,我们看到一个名为“ label”的子节点,带有一个文本条目。 您可能会看到 图2 所示的代码结构 这就是Swing DSL的强大功能。 代码和UI非常相似,在读取DSL时很容易遵循组件的结构。

现在,我们已经在视图中看到了一些代码,让我们继续这个MVC成员,我们稍后将介绍另外两个。 本着保持简单的精神,我们将更新UI,使其类似于图3。

图3:地址簿应用程序的新外观
让我们分解每个部分。 在左侧,我们看到标题为“联系人”的空白区域。 这将是一个列表,其中包含我们通讯录中的所有联系人。 在中间,我们看到一个表格,可用于编辑列表中特定联系人的详细信息。 接下来,在右侧我们发现了一系列按钮,这些按钮将对联系人执行操作。 您可能还会喜欢一个名为“联系人”的菜单,其中包含与按钮同名的菜单项。 清单2描述了更新的AddressbookView。
清单2 – AddressbookView更新了

 

package addressbook
application(title: 'Addressbook',
  pack: true,
  resizable: false,
  locationByPlatform:true,
  iconImage: imageIcon('/griffon-icon-48x48.png').image,
  iconImages: [imageIcon('/griffon-icon-48x48.png').image,
               imageIcon('/griffon-icon-32x32.png').image,
               imageIcon('/griffon-icon-16x16.png').image]) {
    menuBar {
        menu('Contacts') {
            controller.griffonClass.actionNames.each { name ->
                menuItem(getVariable(name))
            }            
        }
    }
    migLayout(layoutConstraints: 'fill')
    list(model: eventListModel(source: model.contacts), 
         constraints: 'west, w 180! ',
         border: titledBorder(title: 'Contacts'),
         selectionMode: ListSelectionModel.SINGLE_SELECTION,
         keyReleased: { e ->  // enter/return key
             if (e.keyCode != KeyEvent.VK_ENTER) return
             int index = e.source.selectedIndex
             if (index > -1) model.selectedIndex = index
         },
         mouseClicked: { e -> // double click
             if (e.clickCount != 2) return
             int index = e.source.locationToIndex(e.point)
             if (index > -1) model.selectedIndex = index
         })
    panel(constraints: 'center', border: titledBorder(title: 'Contact')) {
        migLayout(layoutConstraints: 'fill')
        for(propName in Contact.PROPERTIES) {
            label(text: GriffonNameUtils.getNaturalName(propName) + ': ',
                        constraints: 'right')
            textField(columns: 30, constraints: 'grow, wrap',
                text: bind(propName, source: model.currentContact,
                           mutual: true))
        }
    }
    panel(constraints: 'east', border: titledBorder(title: 'Actions')) {
        migLayout()
        controller.griffonClass.actionNames.each { name ->
            button(getVariable(name), constraints: 'growx, wrap')
        }
    }
}
我们可以看到前面提到的menuBar节点。 我们还可以看到三个主要组成部分:将放置在西侧的列表; 中间的面板,以某种方式为在属性列表中找到的每个属性创建一个对或标签以及textField。 然后,我们看到第三个组件,一个包含按钮的面板。 乍一看,该代码可能看起来有些神奇,但实际上,我们正在利用Griffon制定的约定。 View MVC成员可以访问其他两个成员,即Model和Controller。 视图的工作是布置UI组件。 控制器的工作是保持对用户输入做出React的逻辑。 模型的工作是充当View和Controller之间的沟通桥梁。 我们可以在View中看到对模型和控制器变量的引用; 这些变量指向它们各自的MVC成员。

接下来,我们将更新在griffon- app / models / addressbook / AddressbookModel.groovy中找到的 模型 在这里,我们将确保模型在内存中保留联系人列表; 它还将包含对当前正在编辑的联系人的引用。 我们将使用GlazedLists(在Swing开发人员中很流行的选择)来组织联系人列表。 清单3显示了构建列表并保留对当前编辑的联系人的引用所需的所有代码。 现在,联系人列表具有相对于其元素定义的特殊绑定。 每当元素被编辑时,它将发布一个更改事件,列表将拦截该事件; 该列表将依次更新对列表更改感兴趣的任何人。 回顾清单2,您可以看到列表定义使用了listEventModel节点。 这正是在有可用更新时通知UI的组件,它会重绘受影响的区域。 而且我们只需连接一对组件即可实现! AddressbookModel 与两个特定于域的类一起使用: Contact ContactPresentationModel 第一个可以看作是普通域类,因为它定义了根据简单属性应具有的联系方式。 后者是 Contact 类的 一个可观察的包装器 表示模型通常是可以支持绑定操作的装饰器。 我们马上将看到这两个类。

 

package addressbook
import groovy.beans.Bindable
import ca.odell.glazedlists.*
import griffon.transform.PropertyListener
import java.beans.PropertyChangeEvent
import java.beans.PropertyChangeListener

class AddressbookModel {
    final EventList<ContactPresentationModel> contacts = 
             new ObservableElementList<ContactPresentationModel>(
        GlazedLists.threadSafeList(
        new BasicEventList<ContactPresentationModel>()),
        GlazedLists.beanConnector(ContactPresentationModel)
    )
    
    final ContactPresentationModel currentContact = new ContactPresentationModel()
    
    @PropertyListener(selectionUpdater)
    @Bindable int selectedIndex = -1
    
    private selectionUpdater = { e ->
        currentContact.contact = contacts[selectedIndex].contact
    }
    
    AddressbookModel() {
        currentContact.addPropertyChangeListener(new ModelUpdater())
    }
    
    private class ModelUpdater implements PropertyChangeListener {
        void propertyChange(PropertyChangeEvent e) {
            if(e.propertyName == ‘contact’ || selectedIndex < 0) return
            contacts[selectedIndex][e.propertyName] = e.newValue
        }
    }

    void removeContact(Contact contact) {
        currentContact.contact = null
        ContactPresentationModel toDelete = contacts.find { 
            it.contact == contact 
        }
        if(toDelete != null) contacts.remove(toDelete)
    }
}

但是在显示域之前,让我们介绍最后的MVC成员:控制器。 控制器的工作是响应用户输入并协调信息流。 现在,我们只需要填写空白即可使应用程序再次运行,例如,将清单4中所示的代码粘贴到 griffon-app / controllers / addresbook / AddressbookController.groovy中。

清单4
package addressbook
class AddressbookController {
    def model
    void newAction(evt) { }
    void saveAction(evt) { }  
    void deleteAction(evt) { }

您还记得模型在控制器和视图之间中介数据吗? 这就是为什么 控制器 具有模型属性的原因 Griffon采用了一种基本的依赖项注入机制,该机制将确保每个MVC成员都可以与其他两个成员进行对话,只要在各自的类中定义了某些属性即可。

如果您是Grails开发人员,您可能已经注意到我们不是从对域建模开始的,这在使用Grails应用程序时很常见。 做出此选择有两个原因。 首先,向您展示MVC成员的基础知识以及它们之间的交互方式。 第二个问题是Griffon不支持现成的域类,至少不像Grails所理解的那样,即不支持GORM API。 但是我们可以通过编写简单的域类来管理自己。 例如, 清单5显示了 Contact 域类的外观。

package addressbook
@groovy.transform.EqualsAndHashCode
class Contact {
    long id
    String name
    String lastname
    String address
    String company
    String email
    
    String toString() { "$name $lastname : $email" }
    
    static final List<String> PROPERTIES = ['name', 'lastname', 
         'address', 'company', 'email']

可以在文件 src / main / addressbook / Contact.groovy中 定义此类 在它旁边,我们将在 src / main / addressbook / ContactPresentationModel.groovy中 定义伴随演示模型,并 在其中显示清单6中的代码。

清单6
package addressbook
import groovy.beans.Bindable
@griffon.transform.PropertyListener(propertyUpdater)
class ContactPresentationModel {
    // attributes
    @Bindable String name
    @Bindable String lastname
    @Bindable String address
    @Bindable String company
    @Bindable String email
    
    // model reference
    @Bindable Contact contact = new Contact()
    
    private propertyUpdater = { e ->
        if(e.propertyName == 'contact') {
            for(property in Contact.PROPERTIES) {
                def bean = e.newValue
                delegate[property] = bean != null ? bean[property] : null
            }
        }
    }

    String toString() { "$name $lastname" }

    void updateContact() {
        if(contact) {
            for(property in Contact.PROPERTIES) {
                contact[property] = this[property]
            }
        }
    }
}

如前所述,领域类的设计很简单; 它只需要关心我们要保留的数据。 另一方面,表示模型通过具有相同的属性但进行了少许修改来反映域类:它们每个都是可观察的。 这意味着只要这些属性中的任何一个的值发生更改,就会触发一个事件。 这些事件是启用绑定的事件。 Griffon使用 @Bindable 批注来指示Groovy编译器向字节码中注入一组指令,从而使此类成为可观察的指令。 @Bindable 属于在Groovy语言中发现的一组特殊接口,它们为字节码操作打开了大门。 该集合称为 AST转换 这段代码中还有另一个AST转换,它是 @PropertyListener 这种转换是在特定类和属性上定义和附加PropertyChangeListener的一种好方法。 在我们的案例中,我们将附加一个 PropertyChangeListener ,它对所有属性更改做出React。 清单6中所有代码的下一个作用是,将 Contact 实例附加到 ContactPresentationModels 的实例时 ,所有属性值都将从联系人复制到模型。 调用 updateContact() 方法 时,反向操作将生效

好。 我们几乎准备好再次运行该应用程序。 但是在我们这样做之前,我们必须安装一组插件,这将使我们的生活更轻松。 我们说过我们将使用GlazedLists。 该库由插件提供,因此我们将对其进行安装。 在视图中,我们使用了MigLayout,因此我们还将为其安装一个插件。 最后,我们将安装另一个插件,使基于控制器方法创建UI动作变得轻而易举。 转到控制台提示符,然后键入以下命令

$ griffon install-plugin玻璃列表

$ griffon install-plugin miglayout

$ griffon install-plugin操作

在每个插件安装完成几行输出之后,我们具有再次运行该应用程序所需的全部。

保持联系

是时候完成申请了。 我们前面还有一些任务:
  • 填写每个控制器操作所需的代码
  • 将联系人列表保存到数据库
  • 确保在应用程序启动时从数据库加载联系人
填充动作是一项简单的操作,因为我们已经设置的绑定已经处理了大部分数据操作。 清单7显示了AddressbookController的最终代码。 清单7 – AddressbookController
package addressbook
class AddressbookController {
    def model
    def storageService

    void newAction(evt) {
        model.selectedIndex = -1
        model.currentContact.contact = new Contact()
    }
    
    void saveAction(evt) {
        // push changes to domain object
        model.currentContact.updateContact()
        boolean isNew = model.currentContact.contact.id < 1
        // save to db
        storageService.store(model.currentContact.contact)
        // if is a new contact, add it to the list
        if(isNew) {
            def cpm = new ContactPresentationModel()
            cpm.contact = model.currentContact.contact
            model.contacts << cpm
        }
    }
    
    void deleteAction(evt) {
        if(model.currentContact.contact && model.currentContact.contact.id) {
            // delete from db
            storageService.remove(model.currentContact.contact)
            // remove from contact list
            execInsideUIAsync {
                model.removeContact(model.currentContact.contact)
                model.selectedIndex = -1
            }
        }
    }
    
    void dumpAction(evt) {
        storageService.dump()
    }

    void mvcGroupInit(Map args) {
        execFuture {
            List<ContactPresentationModel> list = storageService.load().collect([]) {
                new ContactPresentationModel(contact: it)
            }
            execInsideUIAsync {
                model.contacts.addAll(list)
            }
        }
    }
}

第一个动作 newAction 是通过重置当前选择(如果有)并创建一个空的Contact来实现的。 saveAction() 应该推动从演示模型回域对象的变化,将数据存储在数据库中,在情况下,这是一个新的联系人,将其添加到联系人列表。 请注意,在处理数据库问题时,我们将委派给另一个名为storageService的组件,在完成对Controller的描述之后,我们将立即看到该组件。 第三个操作首先通过从数据库中删除联系人,然后从联系人列表中删除联系人来删除该联系人。 我们添加了第四项操作,该操作将用于将数据库内容转储到控制台中。 在代码中找到的最后一条信息与从数据库加载数据并填写联系人列表有关。 方法名称很特殊,因为它是MVC生命周期的挂钩。 在实例化所有MVC成员之后,将调用此特定方法,将其视为成员初始化器。 我们准备看一下storageService组件。

Griffon中的服务不过是常规类,但它们会从框架中得到特殊待遇。 例如,它们被视为单例,只要成员定义名称与服务名称匹配的属性,它们就会自动注入MVC​​成员中。 掌握了这些知识之后,我们将创建一个 StorageService 类,如下所示:

$ griffon创建服务存储

这将在 griffon-app / services / addressbook / StorageService.groovy中 创建一个 具有默认内容的文件。 清单8显示了必须放在该文件中的代码才能使应用程序正常工作。

package addressbook
class StorageService {
    List<Contact> load() {
        withSql { dsName, sql ->
            List tmpList = []
            sql.eachRow('SELECT * FROM contacts') { rs ->
                tmpList << new Contact(
                    id:       rs.id,
                    name:     rs.name,
                    lastname: rs.lastname,
                    address:  rs.address,
                    company:  rs.company,
                    email:    rs.email
                )
            }
            tmpList
        }
    }

    void store(Contact contact) {
        if(contact.id < 1) {
            // save
            withSql { dsName, sql ->
                String query = 'select max(id) max from contacts'
                contact.id = (sql.firstRow(query).max as long) + 1
                List params = [contact.id]
                for(property in Contact.PROPERTIES) {
                    params << contact[property]
                }
                String size = Contact.PROPERTIES.size()
                String columnNames = 'id, ' + Contact.PROPERTIES.join(', ')
                String placeHolders = (['?'] * size + 1)).join(',')
                sql.execute("""insert into contacts ($columnNames)
                   values ($placeHolders""", params)
            }
        } else {
            // update
            withSql { dsName, sql ->
                List params = []
                for(property in Contact.PROPERTIES) {
                    params << contact[property]
                }
                params << contact.id
                String clauses = Contact.PROPERTIES.collect([]) { prop ->
                    "$prop = ?"
                }.join(', ')
                sql.execute("""update contacts
                    set $clauses where id = ?""", params)
            }
        }
    }
    
    void remove(Contact contact) {
        withSql { dsName, sql ->
            sql.execute('delete from contacts where id = ?', [contact.id])
        }
    }
    
    void dump() {
        withSql { dsName, sql ->
            sql.eachRow('SELECT * FROM contacts') { rs ->
                println rs
            } 
        }
    }
}

每个服务方法都使用一个名为 withSql 的方法 如果我们安装另一个插件,则此方法可用。 让我们现在开始:

$ griffon install-plugin gsql

完善。 现在,我们在这个小应用程序中启用了Groovy SQL支持 Groovy SQL是常规SQL之上的另一个DSL。 有了它,您可以使用与对象和对象图非常相似的编程API进行SQL调用。 事实上,您甚至可以应用Groovy闭包,Groovy字符串和其他Groovy技巧,如 StorageService 的实现中所示 在再次启动该应用程序之前,我们还要注意另外两项。 我们必须告诉GSQL插件, 必须将 withSql 方法应用于服务。 其次,我们必须定义数据库模式。 如前所述,还没有GORM API,必须手动定义数据库模式。

通过编辑文件 griffon-app / conf / Config.groovy 并添加以下行 来完成第一个任务

griffon.datasource.injectInto = ['服务']

通过在 griffon-app / resources / schema.ddl中 创建一个 具有以下内容 的文件来完成第二个任务

如果存在联系人,则删除表;

创建表联系人(

id INTEGER NOT NULL PRIMARY KEY,

名称VARCHAR(30)NOT NULL,

姓VARCHAR(30)NOT NULL,

地址VARCHAR(100)NOT NULL,

公司VARCHAR(50)NOT NULL,

电子邮件VARCHAR(100)NOT NULL

);

好,我们完成了。 用一些初始数据为数据库播种怎么样? 在Grails中,这是通过编辑一个名为 BootStrap.groovy 的文件来完成的 ;在Griffon中,这是通过编辑 griffon-app / conf / BootstrapGsql.groovy来完成的 让我们向contacts表添加一个条目,如清单9所示。  
import groovy.sql.Sql
class BootstrapGsql {
    def init = { String dataSourceName = 'default', Sql sql ->
        def contacts = sql.dataSet('contacts')
        contacts.add(
            id: 1,
            name: 'Andres',
            lastname: 'Almiray',
            address: 'Kirschgartenstrasse 5 CH-4051 Switzerland',
            company: 'Canno Engineering AG',
            email: 'andres.almiray@canoo.com'
        )
    }

    def destroy = { String dataSourceName = ‘default’, Sql sql ->
    }
} 

ew 现在我们真的完成了。 再次启动该应用程序。 您应该在联系人列表中看到一个条目, 如图4 所示。 用鼠标选择它,然后按 Enter 或双击它。 这将使联系成为活动联系,并将其所有值置于中间形式。 编辑其某些属性,然后单击“保存” 按钮。 创建一个新联系人并保存它。 现在单击转储按钮。 您应该在输出中看到与在联系人列表中可以找到的条目一样多的行。

我们进行了有关格里芬基础知识的旋风之旅。 当然,仅几页和代码清单中还有更多值得关注的东西。 我希望这足以使您的胃口大开,或者至少考虑尝试一下格里芬。 到目前为止,我们编写的所有行为仅适合291行代码。 不相信我吗 运行以下命令:

$ griffon统计

将出现一个按类型列出所有来源的表,类似于此表:

+ ——————————- + ——- + ——- +

| 姓名| 文件| LOC |

+ ——————————- + ——- + ——- +

| 型号| 1 | 32 |

| 意见| 1 | 45 |

| 控制器| 1 | 39 |

| 服务| 1 | 57 |

| 生命周期 5 | 3 |

| Groovy / Java源代码| 2 | 40 |

| 单元测试| 1 | 13 |

| 集成测试| 1 | 15 |

| 配置| 2 | 47 |

+ ——————————- + ——- + ——- +

| 总计| 15 | 291 |

+ ——————————- + ——- + ——- +

真好 该应用程序的完整源代码可以在GitHub上找到 。 我们可以将更多内容添加到该应用程序中。 例如,没有任何错误处理。 如果SQL不是您的最佳选择,该怎么办? 没问题,您可以选择任何受支持的NoSQL选项 。 或者,Swing不适合您。 没问题,Griffon也支持SWT和JavaFX。 更改UI工具箱将意味着在大多数情况下更改View,同时保持其他组件几乎完好无损。 无疑,您前面有很多选择。

其他资源 www.glazedlists.com www.miglayout.com 本文最初发表于《 JAX杂志– Groovy》。 在这里免费订阅。

翻译自: https://jaxenter.com/tutorial-griffon-building-desktop-applications-with-groovy-104667.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值