上一篇我用咗 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 就可以了。