使用vue2+koa2搭建小應用:FHIR questionaire ,符合國際醫療傳輸問券資料格式之json檔產生器

前言

因為不太想花太多時間在學複雜前端框架,所以挑選的工具都是以簡單輕量為主,因手頭最近在設計FHIR相關的應用,藉此機會來稍為活用一下,建一個小工具可以產出符合FHIR格式的問券資料。

框架挑選上,用vue2koa2這兩個輕便型的前端和後端js來完成一個完整的應用程式開發,並且記錄一下實作過程的坑和概念。

為何要用框架?

因為想把時間用在真正開發生資或是醫療應用,然後快速地跟別人分享。

前陣子因為研究需要,用d3.js來設計一些生醫資料視覺化( R無法滿足複雜的視覺設計,只好再跳坑),因此摸了前端的基本概念,而純用html, css, d3.js的過程,讓我發現實在有必要使用一些框架,來減輕花在非“重要”代碼的時間,於是查找了一些前端的框架,其中vue2讓我頗心動,因為其簡單和輕量的出發點。

另外,koa則是在把之前專案視覺化成果放在google cloud時用到的,同樣輕便簡單的特性符合我後端服務器的需求。其洋蔥式的middleware概念,採用javascript generator的方式,頗富趣味。

FHIR是什麼?

FHIR(Fast Health Interoperability Resource)快速健康照護互通資源,是在世界最大醫療標準機構HL7下針對移動世代。詳細的介紹可以參考林口長庚王子豪教授在台灣精準醫學學會的文章:
快速健康照護互通資源FHIR
基於FHIR標準來整合基因體數據到臨床應用的進展

Questionaire 資料格式

這次主要是希望有一個簡易工具能產出FHIR questionaire資料。

螢幕快照 2018-05-03 上午10.52.13
這是原始的資料格式,可以直接到FHIR文檔中看其他如xml, json, turtle等資料格式。

項目的資料夾架構

這是整個FHIR questionaire產生器項目的資料夾架構:

螢幕快照 2018-04-28 上午11.21.11

實作流程

第一步 使用vue-cli建立webpack project

先假設已經安裝好nods.js的環境

npm install -g vue-cli
vue init webpack-simple my-vue-project

第二步 安裝相關的package

這裡會使用到koa, koa-static package

npm install koa koa-static

第三步 基本架構

會獨立一篇來稍微介紹一下vue的觀念。基本上他用很簡單的方式,把前端的每個要件能以一個系統性的方式構築起來。

螢幕快照 2018-05-03 下午10.43.16

下面是實際上vue工作時的資料夾內檔案架構:

螢幕快照 2018-05-03 下午10.47.50

每個副檔名.vue的檔案,都是vue裡面的要件(components),而最上面的root是App.vue。

Main.js

這是最一開始被執行的entry檔案,裡頭算是文檔的入口,一切就以這為root往下掛載。

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import TreeView from "vue-json-tree-view";

Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
  el: '#app',
  components: { App },
  template: ''
})

Vue.use(TreeView);

App.vue

</pre>
<div id="app"><i class="material-icons">description</i>
<h3>FHIR Questionaire Generator</h3>
Recommendation: choose simple and clarity Id, and subset questions in groups<footer>Created by weiting, 2018</footer></div>
<pre>
import Generator from './components/Generator'

export default {
  name: 'App',
  components: {
    Generator
  }
}

.header{
    font-weight:bolder;
}
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
.material-icons {
  font-size: 30px;
  line-height: 1;
  vertical-align: middle;
  margin: -3px;
  padding-bottom: 1px;
}
div.tree-view-item-node{
  background:grey
}
span.tree-view-item-value{
  color:blue
}

components/Generator.vue

</pre>
<div class="Generator">
<div class="bigbox">
<div class="box">
<div></div>
<div class="buttonPanel"><i class="material-icons">add</i> Add Question Group</div>
</div>
<div class="box">
<div><i class="material-icons">add</i> Add Item</div>
<div>問題類型: Choice Integar Boolean Text <i class="material-icons">add</i></div>
</div>
</div>
<div class="outputbox"></div>
<div><i class="material-icons">add</i>Download JSON</div>
</div>
<pre>
import TreeView from "vue-json-tree-view";
// use plugin
var Vue = require('vue');
Vue.default.use(TreeView);

export default {
  name: 'Generator',
  data(){
   return {
            questionaireIdentifier:'',
            questionaireTitle:'',
            questionairePurpose:'',
            questionaireStatus:'',
            items:[],
            grouplinkId:'',
            grouptext:'',
            groupitems:[],
            itemlinkId:'',
            itemtext:'',
            selected:'',
            itemOption:'',
            itemOptions:[]
            }
  },
  computed:{
    FHIRgroupoutput(){
      let groupPrefix  = this.items.length+'.'+"0.0";
      let itemPrefix   = this.items.length+'.'+this.groupitems.length + '.0'
      let groupitems = this.groupitems.map(item =&gt; item);
      let groupObject = {
                          "linkId":this.grouplinkId,
                          "type":"group",
                          "text":this.grouptext,
                          "prefix":groupPrefix
                        };
      let itemObject = {
                          "linkId":this.itemlinkId,
                          "type":this.selected,
                          "text":this.itemtext,
                          "prefix":itemPrefix
                        };
       console.log(itemObject);
      if (this.selected == 'choice'){
       Object.assign(itemObject, {"option":this.itemOptions});
      }
       groupitems.push(itemObject);
       Object.assign(groupObject, {"item":groupitems});
       return groupObject;

    },
    FHIRoutput(){
        let FHIRquestion = {"resourceType":"questionaire",
                            "identifier":this.questionaireIdentifier,
                            "purpose":this.questionairePurpose,
                            "status":this.questionaireStatus,
                            "title":this.questionaireTitle,
                            "item":this.items};
        return FHIRquestion;
    }

  },
  methods:{
    addGroup:function(event){
        let groupPrefix  = this.items.length+'.'+"0.0";
        let groupitems = this.groupitems.map(item =&gt; item);
        let itemPrefix = this.items.length+'.'+this.groupitems.length+".0";
        let groupObject = {
                        "linkId":this.grouplinkId,
                        "type":"group",
                        "text":this.grouptext,
                        "prefix":groupPrefix
                      };
        let itemObject = {
                        "linkId":this.itemlinkId,
                        "type":this.selected,
                        "text":this.itemtext,
                        "prefix":itemPrefix
                      };
                      console.log(itemObject);
        if (this.selected == 'choice'){
           Object.assign(itemObject, {"option":this.itemOptions});
        }
        groupitems.push(itemObject);
        Object.assign(groupObject, {"item":groupitems});
        this.items.push(groupObject);
        this.groupitems = [];
    },
    addItem:function(event){
       let groupPrefix  = this.items.length+'.'+"0.0";
       let itemPrefix = this.items.length+'.'+this.groupitems.length+".0";
       let itemObject = {"linkId":this.itemlinkId,
                         "text":this.itemtext,
                         "prefix":itemPrefix};
        if (this.selected == 'choice'){
         Object.assign(itemObject, {"option":this.itemOptions});
        }
        this.groupitems.push(itemObject);
        this.itemOptions = [];

    },
    addOption:function(event){
       let itemOption = this.itemOption;
       this.itemOptions.push(itemOption);

    },
    downloadJSON:function(event){
        let evt = new MouseEvent('click');
        let exportObj = {
                            "resourceType":"questionaire",
                            "identifier":this.questionaireIdentifier,
                            "purpose":this.questionairePurpose,
                            "status":this.questionaireStatus,
                            "title":this.questionaireTitle,
                            "item":this.items
                        };
        let exportName = "test";
        let dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportObj));
        let downloadAnchorNode = document.createElement('a');
        downloadAnchorNode.setAttribute("href",dataStr);
        downloadAnchorNode.setAttribute("download",exportName + ".json");
        downloadAnchorNode.dispatchEvent(evt);
        let canceled = !downloadAnchorNode.dispatchEvent(evt);
        if (canceled) {
                // A handler called preventDefault
                    console.log("canceled");
                } else {
                // None of the handlers called preventDefault
                    console.log("not canceled");
        }

    }
  },
    mounted:function(){

  }
}

<!-- Add "scoped" attribute to limit CSS to this component only -->

.MDinput{
   border:0;
   border-bottom:1px black solid;
}
input.MDinput:focus{
   border:0;
   border-bottom:2px blue solid;
}
.bigbox{
   display:grid;
   grid-template-columns:50% 50%;
}

.outputbox{
    display: grid;
    grid-template-columns: 50% 50% ;
}
.box{
    display: grid;
    grid-template-columns: 50% 50% ;
    border-radius: 3px;
    border: 1px black solid;
    margin: 10px 10px 10px 10px;
}

.itembox{
    border-radius: 3px;
    border: 1px black solid;
    margin: 10px 10px 10px 10px;
}

.buttonPanel{
    margin: 10px 10px 10px 10px;
}
.material-icons {
  font-size: 24px;
  line-height: 1;
  vertical-align: middle;
  margin: -3px;
  padding-bottom: 1px;
}

button:hover {
  background: #63c89b;
}
.smallbutton{
  border-radius: 10px;
  border: none;
  display: inline-block;
  padding: 2px 4px;
  cursor: pointer;
}
button {
  border-radius: 3px;
  border: none;
  display: inline-block;
  padding: 8px 12px;
  cursor: pointer;
  background: #40b883;
  color: white;
}

老實說,這邊的代碼是最原始的,理論上要更modular一點,但總是要有個開始!><
其中,使用到的知識點有:

  1. 如何下載在前端產生的JSON檔案 link1/ link2
  2. vue掛載component的原理
  3. vue中Directive attribute:v-if, v-model, v-on在元件中的使用
  4. 在建立vue元件時的data, methods, computed特性

閱讀參考:
MARKSZ blog:全線開發實戰:用vue2+koa1開發完整的前後端項目(koa2更新)

發表迴響

在下方填入你的資料或按右方圖示以社群網站登入:

WordPress.com 標誌

您的留言將使用 WordPress.com 帳號。 登出 /  變更 )

Google+ photo

您的留言將使用 Google+ 帳號。 登出 /  變更 )

Twitter picture

您的留言將使用 Twitter 帳號。 登出 /  變更 )

Facebook照片

您的留言將使用 Facebook 帳號。 登出 /  變更 )

連結到 %s