Vue -> PWA+Docker

上一篇我用咗 Visual WebGUI 做成 PWA,醜樣到爆,為咗唔好咁落後,於是我再用 Vue 重新寫過,而且加啲色彩,會用 Docker 擺上網應用(其實我試咗 IIS,太多不確定嘅嘢,放棄咗)。

先嚟睇睇個 app 係點嘅:

基本上嘅功能同操作都係同隻 Visual WebGUI 一樣,不過就靚仔好多!

我係用 VSCodium 寫嘅,VSCodium 係 Microsoft 嘅 Visual Studio Code 嘅姊妹作品,不過就冇 Microsoft 嘅 tracking。先嚟睇睇全個 project 嘅 file structure:

我唔會全部都講,淨係講講重要嘅 codes,先睇 package.json:

{
  "name": "x5-easyrip",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "axios": "^0.19.2",
    "core-js": "^3.6.4",
    "register-service-worker": "^1.6.2",
    "vue": "^2.6.11",
    "vue-file-agent": "^1.6.1-beta.2",
    "vue-i18n": "^8.15.5",
    "vue-js-cookie": "^2.1.0",
    "vue-router": "^3.1.5",
    "vuetify": "^2.2.11",
    "vuex": "^3.1.2",
    "vuex-persist": "^2.2.0"
  },
  "devDependencies": {
    "@intlify/vue-i18n-loader": "^0.6.1",
    "@vue/cli-plugin-babel": "^4.2.0",
    "@vue/cli-plugin-eslint": "^4.2.0",
    "@vue/cli-plugin-pwa": "^4.2.0",
    "@vue/cli-service": "^4.2.0",
    "babel-eslint": "^10.0.3",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^6.1.2",
    "fibers": "^4.0.2",
    "node-sass": "^4.13.1",
    "sass": "^1.26.3",
    "sass-loader": "^8.0.0",
    "vue-cli-plugin-vuetify": "^2.0.5",
    "vue-template-compiler": "^2.6.11",
    "vuetify-loader": "^1.3.0"
  }
}

 

#13 同 #27 就係用嚟搞 PWA 嘅 Vue plugins。

個 Vue router 放咗喺 router.index.js:

import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeLayout from '@/views/HomeLayout.vue'

Vue.use(VueRouter)

const routes = [
  {
    name: 'home',
    path: '/',
    component: HomeLayout,
    children: [
      {
        name: 'plate',
        path: '/plate',
        alias: '/',
        component: () => import(/* webpackChunkName: "plate_page" */ '@/views/PlatePage')
      }, {
        name: 'film',
        path: '/film',
        component: () => import(/* webpackChunkName: "film_page" */ '@/views/FilmPage')
      }
    ]
  }, {
    name: 'login',
    path: '/login',
    component: () => import(/* webpackChunkName: "login_page" */ '@/views/LoginPage')
  }, {
    name: 'reset',
    path: '/reset',
    component: () => import(/* webpackChunkName: "reset_page" */ '@/views/ResetPage')
  }
]

const router = new VueRouter({
  routes,
  // Use the HTML5 history API (i.e. normal-looking routes)
  // instead of routes with hashes (e.g. example.com/#/about).
  // This may require some server configuration in production:
  // https://router.vuejs.org/en/essentials/history-mode.html#example-server-configurations
  //? comment out this for hash mode
  mode: 'history',
  // Simulate native-like scroll behavior when navigating to a new
  // route and using back/forward buttons.
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { x: 0, y: 0 }
    }
  },
})

export default router

 

個売係 HomeLayout.vue,其他 pages 有 Views.PlatePage、FilmPage、LoginPage、同 ResetPage,要留意嘅係 #42,我用 History mode,Vue 嘅 default 係 Hash, 用咗 History 嘅話就要有 nginx.conf 喱個檔案,因為要搞搞 nginx 叫 nginx 唔好出 blank page(當 user click 個 browser refresh 掣),點解要用?History mode 個 browser address URL 會正常啲,冇咗個#喺中間,不過,如果個 user 一定會 install 個 PWA 就應該冇咩 visual 嘅問題,因為安裝咗嘅 PWA 喺 run 嘅時候係唔會顯示個 browser menu bar。

睇睇隻 Views.HomeLayout.vue:

<script>
import {localeMixin} from '@/utils/locale-mixin'

export default {
  mixins: [localeMixin],

  data () {
    return {
      sidebar: false,
      bottomNav: '',
      isLoggedIn: false,
      snackbar: false,
      waiting: false,
    }
  },

  mounted () {
    // 根據 localStorage 資料修正 isLoggedIn
    this.isLoggedIn = localStorage.getItem('user-jwt-token') === undefined ?
        false : localStorage.getItem('user-jwt-token') == null ?
        false : true;
    // 根據 localStorage.landing-page 數值修正 bottomNav,顯示 highlighted
    this.bottomNav = localStorage.getItem('landing-page') === undefined ?
        'plate' : localStorage.getItem('landing-page');
    window.console.log('Home mounted');
    // this.$router.push({name: localStorage.getItem('landing-page') === undefined ? 'plate' : localStorage.getItem('landing-page')})
  },

  computed: {
    currentLocale () {
      return this.$i18n.locale.toUpperCase();
    },

    userAlias () {
      return localStorage.getItem('user-alias') === undefined ? '' : localStorage.getItem('user-alias'); 
    },

  },

  mutations: {
  },

  methods: {
    changeLocale () {
      switch (this.currentLocale) {
        case 'EN':
          this.$store.commit('SET_APP_LOCALE', 'hk')
          break;
        case 'HK':
          this.$store.commit('SET_APP_LOCALE', 'cn')
          break;
        case 'CN':
          this.$store.commit('SET_APP_LOCALE', 'en')
          break;
      }
    },

    toggleTheme () {
      window.getApp.$emit('APP_DARK_THEME_TOGGLED');
    },

    logout () {
      localStorage.removeItem('user-jwt-token');
      this.isLoggedIn = false;
      this.snackbar = true;
    },

    setIsLoggedIn () {
      this.isLoggedIn = true;
    },

    /**
     * 當 bottomNav click 嘅時候,更新 landing-page
     */
    onBottomNavClick (val, tag) {
      window.console.log( val, tag, this.bottomNav );

      localStorage.setItem('landing-page', tag == 'btnPlate' ? 'plate' : 'film');
      this.bottomNav = tag == 'btnPlate' ? 'plate' : 'film';
      
      window.console.log( val, tag, this.bottomNav );
    }
  },

  created() {

    window.getApp.$on('APP_WAITING_ON', () => {
      //window.console.log("Before: ", this.$vuetify.theme.dark);

      this.waiting = true;

      window.console.log("Waiting is ON" );
    })

    window.getApp.$on('APP_WAITING_OFF', () => {
      //window.console.log("Before: ", this.$vuetify.theme.dark);

      this.waiting = false;

      window.console.log("Waiting is OFF" );
    })

    window.console.log('Home created');
  }
}
</script>

<template>
  <v-app>

    <!-- App menu -->
    <v-navigation-drawer v-model="sidebar" app>
      <v-list dense nav>

        <v-list-item link to="/login">
          <v-list-item-icon>
            <v-icon>mdi-login</v-icon>
          </v-list-item-icon>
          <v-list-item-content>
            <v-list-item-title>{{ $t('side-nav.login') }}</v-list-item-title>
          </v-list-item-content>
        </v-list-item>
        <v-divider></v-divider>

        <v-list-item link to="/reset">
          <v-list-item-icon>
            <v-icon>mdi-lock-reset</v-icon>
          </v-list-item-icon>
          <v-list-item-content>
            <v-list-item-title>{{ $t('side-nav.reset') }}</v-list-item-title>
          </v-list-item-content>
        </v-list-item>
        <v-divider></v-divider>

      </v-list>
    </v-navigation-drawer>
    
    <!-- App bar -->
    <v-app-bar dense app>
      <v-app-bar-nav-icon @click="sidebar = !sidebar" />

      <v-toolbar-title>
        {{ $t('app-title') }}
      </v-toolbar-title>

      <v-spacer/>

      <!-- 轉換風格 -->
      <v-tooltip bottom>
        <template v-slot:activator="{ on }">
          <v-btn icon @click="toggleTheme" v-on="on">
            <v-icon>mdi-circle-half-full</v-icon>
          </v-btn>
        </template>
        <span>{{ $t('theme') }}</span>
      </v-tooltip>
    
      <!-- 轉換語言 -->
      <v-tooltip bottom>
        <template v-slot:activator="{ on }">
          <v-btn text icon @click="changeLocale" v-on="on">
            {{ currentLocale }}
          </v-btn>
        </template>
        <span>{{ $t('lang') }}</span>
      </v-tooltip>

      <!-- 用戶登入 -->
      <v-tooltip bottom>
        <template v-slot:activator="{ on }">
          <v-btn icon @click="logout" v-on="on" v-if="isLoggedIn">
            <v-icon>mdi-account</v-icon>
          </v-btn>
        </template>
        <span>{{ $t('login.logout') }} {{ userAlias }}</span>
      </v-tooltip>
    </v-app-bar>


    <!-- 當 logout 完成,彈出 snackbar -->
    <v-snackbar
      v-model="snackbar"
      >
      {{ $t('login.loggedOut') }}
      <v-btn
        color="pink"
        text
        @click="snackbar = false"
        >
        {{ $t('close') }}
      </v-btn>
    </v-snackbar>

    <keep-alive>
      <router-view />
    </keep-alive>

    <!-- Bottom nav bar -->
    <v-bottom-navigation grow v-model="bottomNav" >
      <v-btn value="plate" to="/plate" @click="onBottomNavClick( $event, 'btnPlate' )" >
        <span>{{ $t('main-nav.plate') }}</span>
        <v-icon>mdi-alpha-p-box</v-icon>
      </v-btn>
      <v-btn value="film" to="/film" @click="onBottomNavClick( $event, 'btnFilm' )" >
        <span>{{ $t('main-nav.film') }}</span>
        <v-icon>mdi-alpha-f-box-outline</v-icon>
      </v-btn>
    </v-bottom-navigation>

    <!-- 上傳中:一個住屏 modal form -->
    <v-dialog v-model="waiting" fullscreen >
      <v-container fluid fill-height style="background-color: rgba(255, 255, 255, 0.5);">
        <v-layout justify-center align-center>
          <v-progress-circular
            indeterminate
            color="primary"
            :size = "80">
            {{ $t('upload.waiting') }}
          </v-progress-circular>
        </v-layout>
      </v-container>
    </v-dialog>

  </v-app>
</template>

<style scoped>
/*
 * 當第一次 load 嘅時候,我會自動選用上次嘅 page,
 * 個 bottomNav button 唔識 highlight,要加 CSS 叫佢轉色
 */
.theme--light .v-btn--active {
  background-color: #F5F5F5 !important;
}
.theme--dark .v-btn--active {
  background-color: #464646 !important;
}
</style>

 

HomeLayout.vue 有 Toolbar、Navigation Drawer、Bottom Navigation Bar,配合其他 Views pages 變成大家睇到嘅 app。

#227~#238 係 optional,加咗會舒服啲,最起碼我係咁諗。 😆

都同大家去睇睇其中一頁喇,Views.FilmPage.vue:

<script>
  import helper from '@/utils/helper'

  export default {
    data() {
      return {
        chkPositive: true,
        chkNegative: false,
        chkEmulsionUp: true,
        chkEmulsionDown: false,
        chkColorSeparation: false,

        fileRecords: [],
        loading: false,
        cmdUpload: 1,
        snackbar: false,
        snackbarMessage: null,
      };
    },

    computed: {
      getSizeErrorText () {
        return helper.getText('uploader.upload-size-error');
      },
    },

    /**
     * 由 localStorage 攞番上次嘅選擇出嚟
     */
    mounted() {
      this.chkPositive = JSON.parse(localStorage.getItem('film-positive')) === true;
      this.chkNegative = JSON.parse(localStorage.getItem('film-negative')) === true;
      this.chkEmulsionUp = JSON.parse(localStorage.getItem('film-emulsion-up')) === true;
      this.chkEmulsionDown = JSON.parse(localStorage.getItem('film-emulsion-down')) === true;
      this.chkColorSeparation = JSON.parse(localStorage.getItem('film-color-separation')) === true;
    },
    methods: {
      onOptionChanged(val, tag) {
        window.console.log(val, tag, this.checkbox)
        if (val === null || val.length === 0) {
          window.console.log('Unchecked')
        } else {
          window.console.log('Checked')
        }
        switch (tag) {
          case "chkPositive":
            localStorage.setItem('film-positive', JSON.stringify(this.chkPositive));
            if (val === null || val.length === 0) {
              this.chkNegative = true;
            } else {
              this.chkNegative = false;
            }
            localStorage.setItem('film-negative', JSON.stringify(this.chkNegative));
            break;
          case "chkNegative":
            localStorage.setItem('film-negative', JSON.stringify(this.chkNegative));
            if (val === null || val.length === 0) {
              this.chkPositive = true;
            } else {
              this.chkPositive = false;
            }
            localStorage.setItem('film-positive', JSON.stringify(this.chkPositive));
            break;
          case "chkEmulsionUp":
            localStorage.setItem('film-emulsion-up', JSON.stringify(this.chkEmulsionUp));
            if (val === null || val.length === 0) {
              this.chkEmulsionDown = true;
            } else {
              this.chkEmulsionDown = false;
            }
            localStorage.setItem('film-emulsion-down', JSON.stringify(this.chkEmulsionDown));
            break;
          case "chkEmulsionDown":
            localStorage.setItem('film-emulsion-down', JSON.stringify(this.chkEmulsionDown));
            if (val === null || val.length === 0) {
              this.chkEmulsionUp = true;
            } else {
              this.chkEmulsionUp = false;
            }
            localStorage.setItem('film-emulsion-up', JSON.stringify(this.chkEmulsionUp));
            break;
          case "chkColorSeparation":
            localStorage.setItem('film-color-separation', JSON.stringify(this.chkColorSeparation));
            break;
        }
      },
      filesSelected(val) {
        window.console.log('Selected: ', val);
      },
      fileDeleted(val) {
        window.console.log('Deleted: ', val);
      },
      /**
       * uploadFiles: on Upload button click
       */
      uploadFilesOneByOne() {
        window.console.log("Uploading...");
        this.loading = true;      // 顯示進度條

        for (const fileRecord of this.fileRecords) {
          window.console.log(fileRecord.file);
          window.console.log("File Type: ", helper.getFileExtension(fileRecord.file.name).toLowerCase());

          this.uploadOneFile( fileRecord );  // 續個檔案上傳
        }
        
        window.console.log("Upload...done");
        this.snackbarMessage = this.$t( 'upload.succeed' );
        this.snackbar = true;     // 題示用戶
        this.loading = false;     // 收起進度條
        this.fileRecords = [];    // 清理 uploader
        this.cmdUpload++;         // 復完 上傳 button
      },
      /** 
       * uploadFile: 正式上傳檔案
       */
      uploadOneFile( fileRecord ) {
        const suffix = helper.getFileExtension(fileRecord.file.name).toLowerCase();

        // 用 FormData 傳送 client 資料去 server
        const formData = new FormData();
          formData.append( "positive", this.chkPositive );
          formData.append( "negative", this.chkNegative );
          formData.append( "emulsion-up", this.chkEmulsionUp );
          formData.append( "emulsion-down", this.chkEmulsionDown );
          formData.append( "eolor-separation", this.chkColorSeparation );
          formData.append( "upload-file", fileRecord.file );                    // attach the file

        var _UploadUrl = 'https://rest.directoutput.com.hk/api/easyrip/film/' ;

        window.console.log( "Endpoint: ", _UploadUrl );

        /**
         * 參考:https://javascript.info/xmlhttprequest
         */
        const xhr = new XMLHttpRequest();                                       // 1. Create a new XMLHttpRequest object

          xhr.open( "POST", _UploadUrl, true );                                 // 2. Configure it: async = true

          // xhr.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' ); 講明係 Form-Data 反而唔 work,取消,交俾 browser 決定
          xhr.setRequestHeader( 'Accept', suffix == "pdf" ? 'application/pdf' : 'application/postscript' );
          xhr.setRequestHeader( 'Authorization', `Bearer "${localStorage.getItem('user-jwt-token')}"` );

          xhr.onprogress = function(progressEvent) {
            if (progressEvent.lengthComputable) {
              const percentCompleted = (progressEvent.loaded * 100) / progressEvent.total;
              fileRecord.progress(percentCompleted);    // will update the preview UI 
            }
          };
          xhr.timeout = 60 * 60 * 1000;   // Set timeout to 60 * 60 * 1 seconds (1 hour)
          xhr.ontimeout = function () { alert("Timed out!!!"); }

          xhr.send( formData ) ;                                                // 3. Send the request over the network
          
          xhr.onload = function() {                                             // 4. This will be called after the response is received
            if (xhr.status != 200) { // analyze HTTP status of the response
              window.console.log(`Error ${xhr.status}: ${xhr.statusText}`); // e.g. 404: Not Found
            } else { // show the result
              window.console.log(`Done, got ${xhr.response.length} bytes`); // responseText is the server
            }
          };

          xhr.onprogress = function( progressEvent ) {
            if (progressEvent.lengthComputable) {
              const percentCompleted = (progressEvent.loaded * 100) / progressEvent.total;
              fileRecord.progress( percentCompleted );    // will update the preview UI 

              window.console.log(`Received ${progressEvent.loaded} of ${progressEvent.total} bytes`);
            } else {
              window.console.log(`Received ${progressEvent.loaded} bytes`); // no Content-Length
            }
          };

          xhr.onerror = function() {
            window.console.log("Request failed");
            
            this.snackbarMessage = this.$t( 'upload.failed' );
            this.snackbar = true;
          };          
      },
      /** 
       * uploadAllFiles: 一次過,上傳哂所有檔案
       */
      uploadAllFiles ( ) {
        // const suffix = getFileExtension(fileRecord.file.name).toLowerCase();

        // 用 FormData 傳送 client 資料去 server
        const formData = new FormData();
          formData.append( "positive", this.chkPositive );
          formData.append( "negative", this.chkNegative );
          formData.append( "emulsion-up", this.chkEmulsionUp );
          formData.append( "emulsion-down", this.chkEmulsionDown );
          formData.append( "eolor-separation", this.chkColorSeparation );

          for (const fileRecord of this.fileRecords) {
            formData.append( "upload-file", fileRecord.file );                  // attach the file
          }

        var _UploadUrl = 'https://rest.directoutput.com.hk/api/easyrip/film/' ;

        window.console.log( "Endpoint: ", _UploadUrl );

        /**
         * 參考:https://javascript.info/xmlhttprequest
         */
        const xhr = new XMLHttpRequest();                                       // 1. Create a new XMLHttpRequest object

          xhr.upload.addEventListener("loadstart", function (e) {
            window.console.log( 'Upload started', e.total );

            window.getApp.$emit('APP_WAITING_ON');  //! 開始,顯示 waiting screen

          }, false);

          xhr.open( "POST", _UploadUrl, true );                                 // 2. Configure it: async = true

          // xhr.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded' ); 講明係 Form-Data 反而唔 work,取消,交俾 browser 決定
          // xhr.setRequestHeader( 'Accept', suffix == "pdf" ? 'application/pdf' : 'application/postscript' );
          xhr.setRequestHeader( 'Accept', '*/*' );
          xhr.setRequestHeader( 'Authorization', `Bearer "${localStorage.getItem('user-jwt-token')}"` );
          
          xhr.timeout = 60 * 60 * 1000;   // Set timeout to 60 * 60 * 1 seconds (1 hour)
          xhr.ontimeout = function () { alert( "Timed out!!!" ); }

          xhr.send( formData ) ;                                                // 3. Send the request over the network

          xhr.onload = function() {                                             // 4. This will be called after the response is received
            if (xhr.status != 200) { // analyze HTTP status of the response
              window.console.log(`Error ${xhr.status}: ${xhr.statusText}`); // e.g. 404: Not Found
            } else { // show the result
              window.console.log( `Done, got ${xhr.response.length} bytes` ); // responseText is the server
            }
            window.getApp.$emit('APP_WAITING_OFF'); //! 完,取消 waiting screen
          };

          xhr.onerror = function() {
            window.console.log("Request failed");

            window.getApp.$emit('APP_WAITING_OFF'); //! 完,取消 waiting screen
            this.snackbarMessage = this.$t( 'upload.failed' );
            this.snackbar = true;
          };          

        this.fileRecords = [];    // 清理 uploader
        this.cmdUpload++;         // 復完 上傳 button
      },
    },
  }
</script>

<template>
  <v-content>
    <v-container>
      <v-layout text-center>
        <v-flex>
          <v-alert text >
            {{ $t('film.title') }}
          </v-alert>
        </v-flex>
      </v-layout>
      <v-row no-gutters align="center">
        <v-col cols=3>
          <v-checkbox v-model="chkPositive" class="mx-2" :label="$t('film.positive')" :value="chkPositive"
            @change="onOptionChanged($event, 'chkPositive')">
          </v-checkbox>
        </v-col>
        <v-col cols=3>
          <v-checkbox v-model="chkNegative" class="mx-2" :label="$t('film.negative')" :value="chkNegative"
            @change="onOptionChanged($event, 'chkNegative')">
          </v-checkbox>
        </v-col>
        <v-col cols=3>
          <v-checkbox v-model="chkEmulsionUp" class="mx-2" :label="$t('film.emulsion-up')" :value="chkEmulsionUp"
            @change="onOptionChanged($event, 'chkEmulsionUp')">
          </v-checkbox>
        </v-col>
        <v-col cols=3>
          <v-checkbox v-model="chkEmulsionDown" class="mx-2" :label="$t('film.emulsion-down')" :value="chkEmulsionDown"
            @change="onOptionChanged($event, 'chkEmulsionDown')">
          </v-checkbox>
        </v-col>
      </v-row>
      <v-divider></v-divider>
      <v-row no-gutters align="center">
        <v-col cols=12>
          <v-checkbox v-model="chkColorSeparation" class="mx-2" :label="$t('film.color-separation')" :value="chkColorSeparation"
          @change="onOptionChanged($event, 'chkColorSeparation')">
          </v-checkbox>
        </v-col>
      </v-row>

      <!-- File Uploader -->
      <VueFileAgent ref="vueFileAgent" :theme="'list'" :multiple="true" :deletable="true" :meta="true"
        :accept="'.pdf,.ps'" :maxSize="'512MB'" :maxFiles="10" 
        :helpText="$t('uploader.upload-help-text')"
        :errorText="{
            type: this.$t('uploader.upload-type-error'),    //? 兩種方法都淨係出英文
            size: this.getSizeErrorText,                    //? 奇怪?
          }" 
        @select="filesSelected($event)" @delete="fileDeleted($event)" v-model="fileRecords">
      </VueFileAgent>
      <v-layout justify-end>
        <v-btn class="ma-2" outlined @click.once="uploadAllFiles" :key="cmdUpload" >{{ $t('uploader.upload') }}</v-btn>
      </v-layout>

      <!-- 上傳檔案,進行中,進度條 -->
      <v-progress-linear
        :active="loading"
        :indeterminate="true"
        class="ma-0"
        height="4"
        style="top: -2px;"
      ></v-progress-linear>

      <!-- 當 upload failed,彈出 snackbar -->
      <v-snackbar
        v-model="snackbar"
        >
        {{ this.snackbarMessage }}
        <v-btn
          color="pink"
          text
          @click="snackbar = false"
          >
          {{ $t('close') }}
        </v-btn>
      </v-snackbar>
    </v-container>
  </v-content>
</template>

 

入面有 3 個 functions: uploadFilesOneByOne、uploadOneFile、同 uploadAffFiles,大家可能覺得有啲混淆,我係先寫咗 uploadFilesOneByOne,uploadFilesOneByOne 要配合 uploadOneFile 用,不過我發覺因為 threads 嘅問題,每個 upload 幾乎係同時同地平行咁執行,控制唔倒個 modal popup screen,到最後唯有放棄,採用一次過上傳 multiple files(uploadAllFiles)。

#208 要喺 #215 之前,否則就唔會有作用。

噢,你可能會奇怪點解我用 window.console.log 而唔係人人都用嘅 console.log?我最初都係用簡潔版,不過當我 build 嘅時候會出 error,我冇追究原因,於是,好多 window 出咗嚟。 🙄

Okay,輪到 PWA,PWA 由 vue.config.js 開始:

/**
 * 參考:https://medium.com/js-dojo/vuejs-pwa-cache-busting-8d09edd22a31
 */
const manifestJSON = require('./public/manifest.json')

module.exports = {
  /**
   * pass options to @vue/cli-plugin-pwa,參考:https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa
   */
  pwa: {
    name: manifestJSON.short_name,
    themeColor: manifestJSON.theme_color,
    msTileColor: manifestJSON.background_color,
    appleMobileWebAppCapable: 'yes',
    appleMobileWebAppStatusBarStyle: 'black',
    workboxPluginMode: 'InjectManifest',
    workboxOptions: {
      swSrc: 'service-worker.js',
    },
  },

  "transpileDependencies": [
    "vuetify"
  ],

  publicPath: '/',
  
  chainWebpack: config => {
    config.module
      .rule('i18n')
      .resourceQuery(/blockType=i18n/)
      .type('javascript/auto')
      .use('i18n')
      .loader('@intlify/vue-i18n-loader')
  }
}

 

#16 會喺 public.index.html 加插 PWA 要用到嘅 meta、link 等等 code,唔使自己改隻 index.html,方便快捷。

registerServiceWorker.js:

/* eslint-disable no-console */
/**
 * 參考:https://medium.com/js-dojo/vuejs-pwa-cache-busting-8d09edd22a31
 */
import { register } from 'register-service-worker'

// if (process.env.NODE_ENV === 'production') {
  register(`${process.env.BASE_URL}service-worker.js`, {
    ready () {
      window.console.log(
        'Site is ready.\n' +
        'App is being served from cache by a service worker.\n' +
        'For more details, visit https://goo.gl/AFskqB'
      )
    },
    registered () {
      window.console.log('Service worker has been registered.')
    },
    cached () {
      window.console.log('Content has been cached for offline use.')
    },
    updatefound () {
      window.console.log('New content is downloading.')
    },
    updated () {
      window.console.log('New content is available; Refresh...')
      setTimeout(() => {
        window.location.reload(true)
      }, 1000)
    },
    offline () {
      window.console.log('No internet connection found. App is running in offline mode.')
    },
    error (error) {
      window.console.error('Error during service worker registration:', error)
    }
  })
// }

 

好標準嘅 codes,我冇嘢要加,#8 會 load 隻 public/service-worker.js:

// Set this to true for production
var doCache = true;

// Name our cache
var CACHE_NAME = 'x5-easyrip';

// Delete old caches that are not our current one!
self.addEventListener("activate", event => {
    const cacheWhitelist = [CACHE_NAME];
    event.waitUntil(
      caches.keys()
        .then(keyList =>
            Promise.all(keyList.map(key => {
                if (!cacheWhitelist.includes(key)) {
                    console.log('Deleting cache: ' + key);
                    return caches.delete(key);
                }
            }))
        )
    );
});

// The first time the user starts up the PWA, 'install' is triggered.
self.addEventListener('install', function (event) {
    if (doCache) {
        event.waitUntil(
          caches.open(CACHE_NAME)
            .then(function (cache) {
                // Get the assets manifest so we can see what our js file is named
                // This is because webpack hashes it
                fetch("asset-manifest.json")
                    .then(response => {
                        response.json(); 
                    })
                    .then(assets => {
                        // Open a cache and cache our files
                        // We want to cache the page and the main.js generated by webpack
                        // We could also cache any static assets like CSS or images
                        const urlsToCache = [
                            '/'
                            // '/plate',
                            // '/film',
                            // '/login',
                            // '/reset'
                        ];
                        cache.addAll(urlsToCache);
                        console.log('cached');
                    });
            })
        );
    }
});

// When the webpage goes to fetch files, we intercept that request and serve up the matching files
// if we have them
self.addEventListener('fetch', function (event) {
    if (doCache) {
        // SOLUTION: 
        // i) detect your upload endpoint 
        // ii) return so that you can use XHR
        if (event.request.url.indexOf('BaseForm') !== -1) {
            return;
        }
        event.respondWith(
            caches.match(event.request).then(function (response) {
                return response || fetch(event.request);
            })
        );
    }
});

 

喱隻 service-worker.js 我係照抄 Visual WebGUI project 嘅,原本想用 workbox 做嘅,不過搞嚟搞去都唔 work,唯有抄一下之前花咗心機測試好咗嘅啦,粗粗哋用住先。

到咗最後,講 Docker,Dockerfile:

## build stage

# 去 DockerHub 借用 official 嘅 node image:https://hub.docker.com/_/node/
FROM node:lts-alpine as build-stage

# set working directory,參考:https://www.educative.io/edpresso/what-is-the-workdir-command-in-docker
WORKDIR /app

# install and cache app dependencies,然後 build x5-easyrip
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

## production stage,攞隻 nginx image
FROM nginx:stable-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]
# 參考:https://stackoverflow.com/questions/18861300/how-to-run-nginx-within-a-docker-container-without-halting/18861312

## Now let’s build the Docker image of x5-easyrip app:
#  docker build -t x5-easyrip-img .

## Finally, let’s run x5-easyrip app in a Docker container:
#  docker run -it -p 8001:80 --rm --name x5-easyrip x5-easyrip-img

 

  • #4 係整隻 node image 備用
  • #7~#13 係 build 隻 vue app,暫時放喺 /app 內
  • #16 係整隻 nginx image
  • #17 係將 build 好咗嘅 vue app 抄入去隻 image 度
  • #18 將本身嘅 nginx default.conf 刪除
  • #19 抄我寫嘅 nginx.conf 抄入去
  • #25 係手動建立隻 app 嘅 docker app image
  • #28 係手動用 #25 隻 app image 建立隻 docker container

我唔會次次都手動搞 #25 同 #28,我會用 docker-compose 做,於是就多咗隻 docker-compose.yml:

version: '3.3'

services:

  app:
    container_name: x5-easyrip
    image: x5-easyrip-img
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - '8001:80'
# 如果想 runtime 修改個 ngixn.conf,un-comment 以下約行
#    volumes:
#      - x5-easyrip:/etc/nginx/conf.d:rw
#
#volumes:
#  x5-easyrip:

 

好,齊哂,先將所有嘢射上 github.com 然後喺隻 docker server 下載,然後就按步就班,run 一下 docker-compose.yml,通常我會先用:

npm install 安裝啲 vue packages

npm run serve 睇下喺 development mode 跑唔跑到

npm run build 睇下 build 唔 build 到?

一切正常嘅話咁就可以 docker-compose up -d 去 create 隻 docker container,日後如果有嘢要改,就可以改完射上 github,用 git pull 同步一下,用 Portainer 刪除隻 container + images,再直接執行 docker-compose up -d 就可以了。