在 CUBA 应用程序中使用图片

在 CUBA 应用程序中有几种使用图片的方式。在本指南中会演示如何上传和显示图片。也演示了如何将图片附加到实体同时允许用户从应用程序中下载图片文件。

将要构建的内容

本指南对 CUBA 宠物诊所示例进行了增强,允许用户附加图片到实体,并且使用这些图片。特定是进行了下列修改:

  • 允许给兽医上传头像

  • 在兽医浏览界面显示兽医的头像

  • 创建了类似 lookup 的自定义组件,使用这个组件显示将兽医头像显示在姓名旁边

  • 允许给就诊记录附加 X-光照片,也可以查看这些照片

开发环境要求

你的开发环境需要满足以下条件:

下载 并解压本指南的源码,或者使用 git 克隆下来:

示例: CUBA 宠物诊所

这个示例是以 CUBA 宠物诊所项目为基础,而这个项目的基础是众所周知的 Spring 宠物诊所项目。CUBA 宠物诊所应用程序涉及到了宠物诊所的领域模型及与管理一家宠物诊所相关的业务流程。

这个应用程序的领域模型看起来像这样:

领域模型

主要实体是 PetVisit。 一个 Pet 到一家诊所就诊,在诊疗过程(Vist) 会有一名兽医(Vet)负责照顾它。每个宠物都有主人,一个主人可以有多个宠物。一个诊疗过程(Vist)即是一个宠物在主人的帮助下到诊所就诊的活动。

CUBA 文件存储子系统

CUBA 提供了一组综合性的功能用来在应用程序中处理文件。 底层子系统被称作 文件存储

这组 API 为开发人员在文件处理方面提供了端对端(前端到后端)的功能实现:

  • 用户上传文件的功能

  • 在业务实体中保存文件引用

  • 在应用程序中读&写文件

  • 用户下载文件的功能

文件存储子系统从物理文件中分离出文件元数据,文件元数据存储在关系数据库,物理文件存储在文件系统。

关于这个主题的详细信息可参考文档:https://doc.cuba-platform.cn/manual-7.0-chs/file_storage.html[文件存储] 。

由于图片是一种比较特别的文件类型,所以对于图片文件的处理加入了一些特殊的额外行为,比如在浏览器中预览图片。

vet avatar example

兽医头像图片

本指南的第一个示例是允许用户上传并显示特定兽医的头像图片。

要实现这个功能,宠物诊所示例中的数据模型需要进行一些改进,以便在 Vet 实体中存储文件引用。 CUBA 内置的对应 JPA 实体是 FileDescriptor 。 它存储文件的元数据部分, 同时作为指向物理文件的指针。

关系类型是 MANY-TO-ONE 。在 Studio 的实体设计器中结果看起来是这样:

vet entity file descriptor reference

由于实体模型包含对 FileDescriptor 实体的引用,属性 image 必须被添加到视图中,以便在 UI 中使用这个属性。

实体的 vet-with-specialties 视图在兽医(Vet)编辑界面使用,必须修改成包含 image 属性:

views.xml
<view class="com.haulmont.sample.petclinic.entity.vet.Vet"
      extends="_local"
      name="vet-with-specialties-and-image">
    <property name="specialties"
              view="_minimal"/>
    <property name="image" view="_base" />
</view>

进行了这个调整后有必要将视图重命名为 vet-with-specialties-and-image ,以反映出视图内容。

上传兽医头像

满足了上述先决条件后,下一步是调整兽医编辑界面,以允许在创建或编辑兽医记录时可以上传图片。

CUBA 有一个内置的用于处理 FileDescriptor 实例的 UI 组件。这个组件可以添加到 vet-edit.xml 界面描述文件中的 <form /> 表单组件中。

vet-edit.xml
<form id="fieldGroup" dataContainer="vetDc">
    <column width="250px">

        <textField property="firstName"/>
        <textField property="lastName"/>

        <upload id="imageField"
                property="image" (1)
                fileStoragePutMode="IMMEDIATE"
                showFileName="true"
        />

    </column>
</form>
1 FileUploadField 组件将上传的文件绑定到 vetDc 数据容器的 image 属性

有几个控制上传组件行为的选项,可在这里找到: 参考文档 。在当前案例中,使用默认行为就可以了。它允许用户上传、下载、移除和重新上传文件,并且直接将文件附加到编辑界面上的 Vet 实例。

最终用户界面看下来是这样:

vet entity file descriptor reference ui

fileStoragePutMode 属性设置为 IMMEDIAT 时,文件上传完成后会被持久化并且直接创建一个 FileDescriptor 实体实例。这种方式的好处是 Vet 实例会和图片直接关联起来,不需要额外的编程处理。

但是,这种方式有一个副作用: 在 Vet 实例没有保存的情况下,文件仍然会被保存到文件系统。这个文件也可以通过 Administration > External Files 功能查到。 如果这不是想要行为, 可以将 fileStoragePutMode 设置为 MANUAL ,这时就需要手动管理文件的持久化。 接下来的用例会描述这种行为的更多细节。

在浏览表格中显示兽医的头像

下一个示例是处理上传的兽医头像图片,这个图片要显示在兽医浏览界面。要做到这点, image 属性应该出现在相应的视图(根据上面的视图修改而来)

在界面上显示图片的第二个 UI 组件是 <image /> 组件。它允许使用各种来源来渲染一个图片,来源包括:

  • 来自文件系统/classpath 的图片

  • 来自任务 URL 的图片

  • 来自 FileDescriptor 实例

参考 CUBA 文档: 图片 UI 组件 了解更多关于各种选项的信息。

在这种情况下,由于 Vet 通过 image 属性实例持有 FileDescriptor 的引用,所以可以直接渲染文件引用的图片。

最终 UI 看起来是这样:

vet avatar browse screen

image 属性应该是兽医表格中的一列,此外,这列应该渲染一个特定的图片,不应该是 FileDescriptor 实例的实例名,而是图片本身。

要实现这种效果,就需要在控制器中写一些代码。

Table 组件有一个特别的方法: addGeneratedColumn ,这个方法允许为特定的列定义一个组件来作为这个列的渲染器。 这个方法对于每个要显示的实体都调用一次,接受相应的实体实例作为参数。

VetBrowse.java
public class VetBrowse extends StandardLookup<Vet> {

    @Inject
    protected GroupTable<Vet> vetsTable;

    @Inject
    protected UiComponents uiComponents;

    @Subscribe
    protected void onInit(InitEvent event) {
        vetsTable.addGeneratedColumn( (1)
                "image",
                this::renderAvatarImageComponent
        );
    }

    private Component renderAvatarImageComponent(Vet vet) {
        FileDescriptor imageFile = vet.getImage(); (2)
        if (imageFile == null) {
            return null;
        }
        Image image = smallAvatarImage();
        image.setSource(FileDescriptorResource.class)
                .setFileDescriptor(imageFile); (4)

        return image;
    }

    private Image smallAvatarImage() {
        Image image = uiComponents.create(Image.class);  (3)
        image.setScaleMode(Image.ScaleMode.CONTAIN);
        image.setHeight("40");
        image.setWidth("40");
        image.setStyleName("avatar-icon-small");
        return image;
    }
}
1 通过 renderAvatarImageComponent 方法在 vetsTable 表格中注册一个新的列: image
2 通过 Vet 实例的 image 属性获取到 FileDescriptor 引用
3 UI 基础设施 uiComponents bean 是编程方式创建 UI 组件的入口点
4 Image 组件通过 FileDescriptorResource 变体类型绑定到 FileDescriptor

有了这些代码,就可以创建一个 Image 组件、配置好并绑定到 FileDescriptor 实例。使用 CSS 类 avatar-icon-small,图片就可以渲染成上面展示的效果。

创建包含头像的兽医 Lookup 组件

兽医头像功能的最后一部分是就诊详情界面,在兽医姓名的旁边应该显示兽医的头像。

vet avatar example

在这里需要以另外一种方式应用 image 组件,即将其作为 Visit 编辑界面的一部分放在 <form /> 组件中。

实现这个功能的源码分由两部分组成:

  • VetEdit 控制器协调自定义组件的创建和绑定

  • VetPreviewComponentFactory 负责使用合适的布局创建上面显示的组件,并且绑定到 InstanceContainer 的指定字段

使用专门的工厂类来定义创建组件的方式只是一个对实现进行结构化的可能选项。将组件的创建提取到专门的类有几点好处,比如可以做到封装、关注点分离及在其它界面重用。

但是也可以将组件的创建逻辑直接写在控制器中。这种方式可以在一个关于如何在UI层结构化应用程序逻辑的示例中看到。关于这个主题的详细信息可以参考本指南:https://www.cuba-platform.cn/guides/create-business-logic-in-cuba[在 CUBA 中创建应用程序逻辑]。

VetEdit 控制器中创建组件的代码看起来是这样:

VisitEdit.java
public class VisitEdit extends StandardEditor<Visit> {

    @Inject
    protected Form treatingVetForm;

    @Inject
    protected InstanceContainer<Visit> visitDc;

    // ...

    @Subscribe
    protected void renderTreatingVetLayout(AfterShowEvent event) {

        VetPreviewComponentFactory vetPreviewComponentFactory =
            new VetPreviewComponentFactory( (1)
                uiComponents,
                screenBuilders,
                messageBundle,
                this
            );

        Component vetPreview = vetPreviewComponentFactory.create(  (2)
                visitDc,
                vet -> getEditedEntity().setTreatingVet(vet)
        );

        treatingVetForm.add(vetPreview); (3)
    }
}
1 使用必须依赖创建 VetPreviewComponentFactory 实例
2 create 方法创建一个显示 Vet 头像的 image 组件
3 创建的组件附加到 treatingVetForm 来渲染头像图片

The VetPreviewComponentFactory code contains a little bit more code for correct positioning of the elements within the layout. The key points are listed below (the complete class can be found in the example project: VetPreviewComponentFactory.java).

VetPreviewComponentFactory.java
public class VetPreviewComponentFactory {

    private final UiComponents uiComponents;
    private final ScreenBuilders screenBuilders;
    private final FrameOwner frameOwner;

    public Component create( (1)
            InstanceContainer<Visit> visitDc,
            Consumer<Vet> vetSelectionHandler
    ){
        return verticalLayout(
                vetImage(visitDc),
                horizontalLayout(
                        treatingVetName(visitDc),
                        editVetButton(vetSelectionHandler)
                )
        );
    }

    // ...

    private Image vetImage(InstanceContainer<Visit> visitDc) {

        Image image = uiComponents.create(Image.class);
        // ...
        image.setValueSource(
                new ContainerValueSource<>(visitDc, "treatingVet.image") (2)
        );
        return image;
    }

    private Button editVetButton(Consumer<Vet> vetSelectionHandler) {

        LinkButton button = uiComponents.create(LinkButton.class);
        // ...
        button.setAction(
                new BaseAction("changeVet")
                .withHandler(event -> openVetLookup(event, vetSelectionHandler)) (3)
        );
        return button;
    }

    private void openVetLookup(
            Action.ActionPerformedEvent event,
            Consumer<Vet> vetSelectionHandler
    ) {
        screenBuilders.lookup(Vet.class, frameOwner)
                .withOpenMode(OpenMode.DIALOG)
                .withSelectHandler(
                        vets -> vetSelectionHandler.accept(vets.iterator().next())
                )
                .show();
    }
}
1 使用一个 vertical 布局包含 image,一个 horizontal 布局包含名称和创建的 "Edit" 按钮
2 ValueSource 引用了数据容器中关联的 treatingVet 实体的 image 属性
3 "Edit" 按钮的处理器(handler) 触发提供的 vetSelectionHandler ,所以相关的操作可以在工厂方法外部控制

完成这些后,自定义的兽医(Vet)展示选择界面看起来是这样:

vet selection in visit edit

诊疗过程的 X光照片

本指南中涉及到的第二个用例给特定的诊疗记录附加 X光照片。选中表格行时应该显示X光照片。更进一步,也应该可以上传和下载这些照片。

我们需要对数据模型进行一点调整。像前面的 Vet 实体, Visit 实体也需要引用 FileDescriptor , 但是这次是需要使用 MANY-TO-MANY 关系。对领域模型调整后,相应的视图也需要调整来处理X光照片的上传、预览和下载。

上传 X光照片

第一步是上传X光照片到特定的 Visit 实例。这需要将下面的 <upload /> 组件放到 xRayImagesTable 表格的 <buttonsPanel /> 组件中。相较于第一个用例, 这次 fileStoragePutMode 将被设置为 MANUAL ,这会给特定场景下的持久化行为带来更大的自由度。

visit-edit.xml
<upload id="upload"
        showClearButton="false"
        uploadButtonIcon="UPLOAD"
        uploadButtonCaption=""
        fileStoragePutMode="MANUAL" (1)
        permittedExtensions=".png,.jpg,.pdf" (2)
        dropZone="contentHBox" (3)
        showFileName="false"/>
1 持久化操作在控制器中手动处理,以正确地显示图片预览
2 允许上传PDF文件或图片
3 额外的拖放区允许用户拖放文件到表格或预览组件上

相应的 VisitEdit 控制器订阅 upload 组件的 FileUploadSucceedEvent 事件。

VisitEdit.java
public class VisitEdit extends StandardEditor<Visit> {

    @Inject
    protected CollectionPropertyContainer<FileDescriptor> xRayImagesDc;

    @Inject
    protected FileUploadField upload;

    @Inject
    protected DataContext dataContext;

    @Inject
    protected FileUploadingAPI fileUploadingAPI;

    @Subscribe("upload")
    protected void onUploadFileUploadSucceed(
            FileUploadField.FileUploadSucceedEvent event
    ) {
        FileDescriptor imageDescriptor = upload.getFileDescriptor(); (1)

        try {
            fileUploadingAPI.putFileIntoStorage(upload.getFileId(), imageDescriptor); (2)

            FileDescriptor savedImageDescriptor = dataManager.commit(imageDescriptor);
            newImageDescriptors.add(savedImageDescriptor);

            xRayImagesDc.getMutableItems().add(savedImageDescriptor); (3)
            /* ... */
        } catch (FileStorageException e) {
            /* ... */
        }
    }
}
1 为上传的文件创建的 FileDescriptor 实例需要被持久化
2 上传的文件被传送到后端并持久化到文件存储(FileStorage)
3 已经持久化的文件的 FileDescriptor 实例被分配给 X-Ray images M:N 关系,以显示在表格中
除了列出的代码以外, 在示例项目中还有一些相关的代码。 这些代码用于处理用户上传照片后又取消诊疗记录的编辑操作的情况。在这种情况下,上传的图片需要被移除。详细信息请参考示例代码: VisitEdit.java.

通过以上代码实现了X光照片的上传功能,可以进行下一步:预览上传的图片

X光照片预览

要预览上传的X光图片,visit-edit.xml 需要调整一下,以让它能在 X光照片表格的旁边显示预览图。

visit-edit.xml
<hbox id="contentHBox" spacing="true" width="100%">
    <table id="xRayImagesTable"
           dataContainer="xRayImagesDc"
           width="100%"
           height="100%"
           columnControlVisible="false">
        <actions>
            <action id="download" trackSelection="true" icon="DOWNLOAD"/>
            <action id="edit" type="edit"/>
            <action id="remove" type="remove"/>
        </actions>
        <columns>
            <column id="name"/>
        </columns>
    </table>
    <hbox id="xrayImageWrapperLayout"
          height="100%"
          width="100%"
          spacing="true">
    </hbox>
</hbox>

这个用例中最重要的部分是 xrayImageWrapperLayout 组件,它作为一个占位符将在后续用于显示图片。当前它不包含任何子组件,而是在 xRayImagesTable 表格的选中行变化后被动态填充。

VisitEdit.java
public class VisitEdit extends StandardEditor<Visit> {

    @Inject
    protected Table<FileDescriptor> xRayImagesTable;

    @Subscribe("xRayImagesTable")
    protected void onXRayImagesTableSelection(
            Table.SelectionEvent<FileDescriptor> event
    ) {
        xrayImageWrapperLayout.removeAll();
        Set<FileDescriptor> selectedXrayImages = event.getSelected(); (1)
        if (!selectedXrayImages.isEmpty()) {
            xrayImageWrapperLayout.add( (2)
                    xrayImage(
                            selectedXrayImages.iterator().next()
                    )
            );
        }
    }

    private Component xrayImage(FileDescriptor file) {
        XrayPreviewComponentFactory factory = new XrayPreviewComponentFactory(
                uiComponents,
                messageBundle
        );

        return factory.create(file); (3)
    }
}
1 在表格的 selection 事件中获取选中的 FileDescriptor 实体 (X光照片)
2 一个 X-Ray 组件被填充为 xrayImageWrapperLayout 的子组件,用于显示选中的 X光照片
3 创建 X-Ray 图片预览组件的逻辑在 XrayPreviewComponentFactory 类中实现

由于 X光图片预览也是一个多元素的组合,这个逻被提取到一个专门的 工厂 类。 最终UI布局由一个 GroupBoxImage 组件组成。文件显示显示在 GroupBox 的标题上, Image 组件作为 GroupBox 的内容显示。

x ray image component

列出的 XrayPreviewComponentFactory 类只包含了最主要的部分。预览功能中一个重要的部分是能同时支持图片和PDF文件的预览。这需要另外一个分支判断去基于文件类型使用正确的UI组件来实现,

XrayPreviewComponentFactory.java
public class XrayPreviewComponentFactory {

    public Component create(FileDescriptor file) {
        GroupBoxLayout groupBoxLayout = uiComponents.create(GroupBoxLayout.class);
        groupBoxLayout.setShowAsPanel(true); (1)
        groupBoxLayout.setStyleName("well");
        groupBoxLayout.setCaption(
                messageBundle.formatMessage("previewFile", file.getName())
        );
        if (isPdf(file)) {
            groupBoxLayout.add(xrayPdfComponent(file));
        }
        else if (isImage(file)){
            groupBoxLayout.add(xrayImageComponent(file));
        }
        return groupBoxLayout;
    }

    private boolean isPdf(FileDescriptor file) {
        return file.getExtension().contains("pdf");
    }

    // ...

    private Component xrayImageComponent(FileDescriptor imageFile) {
        Image image = uiComponents.create(Image.class);
        image.setScaleMode(Image.ScaleMode.SCALE_DOWN);
        image.setSource(FileDescriptorResource.class)
                .setFileDescriptor(imageFile); (2)
        return image;
    }

    private Component xrayPdfComponent(FileDescriptor imageFile) {
        BrowserFrame browserFrame = uiComponents.create(BrowserFrame.class);
        browserFrame.setSource(FileDescriptorResource.class)
                .setFileDescriptor(imageFile)
                .setMimeType(MediaType.APPLICATION_PDF_VALUE); (3)
        return browserFrame;
    }
}
1 将组件外观设置 Vaadin Panel 外观
2 如果是图片, 则使用 Image 组件, 源将被设置为 FileDescriptor
3 如果是 PDF 文件, 正确的 MIME 类型会被设置以在浏览器中渲染PDF文件

实现了以上两部分,X光照片的预览功能就完成了:

x ray image preview

下载 X光照片

X光照片预览用例的最后一部分是图片文件的下载功能。

为了实现这个功能,一个额外的 download 按钮被在 xRayImagesTable 表格的上边。控制器代码中使用了 CUBA 框架提供的 ExportDisplay Bean 去触发浏览器中对 FileDescriptor 的下载。

VisitEdit.java
public class VisitEdit extends StandardEditor<Visit> {

    @Inject
    protected ExportDisplay exportDisplay;

    @Subscribe("xRayImagesTable.download")
    protected void onXRayImagesTableDownload(Action.ActionPerformedEvent event) {
        downloadFile(xRayImagesTable.getSingleSelected());
    }

    private void downloadFile(FileDescriptor file) {
        exportDisplay.show(file, ExportFormat.OCTET_STREAM);
    }
}

exportDisplay bean 对于文件下载有多个选项。这里使用的是接受 FileDescriptor 的用法 。ExportFormat.OCTET_STREAM 指示浏览器应该强制下载文件而不是将文件渲染在页面中。

总结

CUBA 应用程序中的图片渲染和自定义元素组件可以显著地提高用户体验。主要的构成要素是 FileDescriptor 抽象,所有的UI组件都可以与 FileDescriptor 平滑地集成。

本指南中展示了两个用例。第一个用例中在 <form /> 组件中使用了标准的上传功能上传兽医头像,并且将头像显示在一个表格的生成列中。 另外创建了一个可以显示图片的 "PickerField"。 第二个用例中更直接地使用了 API 来上传、预览和下载 X光照片。