# 5-3 Vuex 整合實戰!以口罩地圖為例

如果讀者從頭跟著本書的節奏一路看到這裡,其實我們已經介紹完 Vue.js 大部分的功能了。 那麼在這個章節的最後,我想用一個實際的案例來帶大家看看一個 Vue.js 搭配 Vuex 專案結構比較完整的樣貌。

# 口罩地圖緣起

這次範例的口罩地圖,其實是在 2020 年初,由政府資料開放平台提供的「健保特約機構口罩剩餘數量明細清單」 https://data.gov.tw/dataset/116285 (opens new window) 所提供的開放資料作為示範。

健保特約機構口罩剩餘數量明細清單

當時因為口罩短缺,導致各地藥局的口罩一度供不應求,政府為確保民眾均能安心且公平購買口罩的機會,臺灣自2020年2月6日起實施「口罩實名」販售制,民眾可憑健保卡至全國各地的健保特約藥局及衛生所購買口罩。為了讓民眾即時查找所在區域的口罩剩餘情形,健保署釋出口罩庫存量及特約藥局與衛生所資訊等開放資料,而當時民間口罩資訊的應用已知有超過 140 種口罩地圖的應用。

透過 OpenData 結合技術推廣,能夠讓更多人接觸開源領域,關心參與公民防疫,即便疫情尚未降溫,這也是令我感到開心且意外的收穫。 雖然在 2021 年的今日,台灣的口罩數量已經不再短缺,但全世界依舊籠罩在疫情的威脅下。

此時我選擇將這個範例寫進書中,除了作為教學使用外,也算是為這起事件留下一點小小的紀錄。

小提醒

以下範例使用的 API 為實際即時資訊,根據過往經驗,每逢週日的時候由於多數藥局無營業,所以 API 回傳的藥局資訊量比平常銳減為正常現象。

# 環境設定

在這個範例中,我們採用本書第三章所介紹過的 Vue CLI 來建立專案。 假設已經安裝好 Vue CLI,我們打開終端機並執行:

$ vue create mask-demo-app
1

接著版本的詢問我們選擇手動挑選: Manually select features

然後會循我們套件的選擇,這裡勾選

Vue CLI v4.5.10
? Please pick a preset: Manually select features
? Check the features needed for your project:
❯◉ Choose Vue version
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◯ Router
 ◉ Vuex
 ◉ CSS Pre-processors
 ◯ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing
1
2
3
4
5
6
7
8
9
10
11
12
13

使用的 Vue.js 的版本當然選 3.x:

? Choose a version of Vue.js that you want to start the project with
  2.x
❯ 3.x (Preview)
1
2
3

CSS 預處理器的部分我們選擇 SASS, dart-sassnode-sass 皆可。

? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default):
❯ Sass/SCSS (with dart-sass)
  Sass/SCSS (with node-sass)
  Less
  Stylus
1
2
3
4
5

設定檔存放到 package.json

? Where do you prefer placing config for Babel, ESLint, etc.?
  In dedicated config files
❯ In package.json
1
2
3

安裝完成後,根據你套件安裝管理,輸入

$ npm run serve
1

或者

$ yarn serve
1

即可開啟專案。


# 專案結構

建立好的專案,目錄結構大致上會是這個樣子:

├── README.md
├── babel.config.js
├── package.json
├── node_modules/
├── public/
│   ├── favicon.ico
│   └── index.html
├── src/
│   ├── App.vue
│   ├── assets/
│   │   └── logo.png
│   ├── components/
│   │   └── HelloWorld.vue
│   ├── main.js
│   └── store/
│       └── index.js
└── yarn.lock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

這裡的檔案結構與我們在第三章介紹過的多了一個 store/index.js ,這就是 Vuex 的 store ,其他部分大同小異。

# 網頁元件結構

以這次的範例來說,我們可以將整個專案切分成三個主要的元件,

分別是主畫面的左側的列表、右側的地圖:

元件結構示意 - 1

以及點擊標記後出現的燈箱:

元件結構示意 - 2

專案的完整結果可以參考: https://kuro.tw/mask-map-demo/ (opens new window)

# 建立基礎介面

首先我們開啟專案內的 /public/index.html,並在 <head>...</head> 加入所需的 CSS:

<!-- reset -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css">
<!-- font-awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.13.0/css/all.min.css">
<!-- leaflet -->
<link rel='stylesheet' href='https://unpkg.com/[email protected]/dist/leaflet.css'>
1
2
3
4
5
6

然後,改寫 src/App.vue 把用不到的地方清掉,並加上對應的 DOM,並且加入 style.scss

<template>
  <div id="app">
    <!-- aside-menu 左側欄 -->
    <div class="aside-menu">
      <!-- 略,詳細模板內容請參考下方連結內 App.vue & style.scss -->
    </div>

    <!-- 地圖區塊 -->
    <div class="mask-map" id="mask-map"></div>
  </div>
</template>

<script>
export default {
  name: 'App',
}
</script>

<style lang="scss" src="./style.scss"></style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

為了不讓 HTML 與 CSS 內容佔據太大篇幅,基礎模板與 CSS 請讀者直接參考這份文件:https://tinyurl.com/008-vuex-demo-1 (opens new window)

(若不幸短網址失效可參考 https://gist.github.com/kurotanshi/348d2f044348a9e7aa8e24313267ca84 (opens new window) )

此時可以試著啟動 dev-server,我們的網頁應該會長這樣:

初次執行的結構

此時畫面什麼都沒有是正常的,因為我們到目前為止只把基礎模板樣式處理好而已,甚至連網頁的內容都是寫死的。

# 拆分元件 - asideMenu.vue

下一步就是要拆分元件了,先到 components/ 目錄下新增一個 asideMenu.vue 檔案,然後將前面在 App.vue 裡的 <div class="aside-menu"> … </div> 整塊搬過來,並且置放到 <template> 區塊:

<!-- asideMenu.vue -->
<template>
  <div class="aside-menu">
    <!-- 中間略 -->
  </div>
</template>

<script>
export default {
  name: 'asideMenu',
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12

然後修改 App.vue 在模板內原本側欄的位置加入 <asideMenu /> ,並將剛剛建立的 asideMenu.vue import 進來:

<template>
  <div id="app">
    <!-- 側欄元件 -->
    <asideMenu />

    <!-- 地圖區塊 -->
    <div class="mask-map" id="mask-map"></div>
  </div>
</template>

<script>
import asideMenu from './components/asideMenu.vue';

export default {
  name: 'App',
  components: {
    asideMenu,
  },
}
</script>

<style lang="scss" src="./style.scss"></style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

這樣就拆好第一個元件了,而且執行的結果跟剛剛一模一樣。

# 取得縣市 & 行政區資料

在建立好基本外觀介面之後,第一件事就要來處理藥局的 縣市/行政區 選單。

首先打開 Vuex store/index.js,修改 state 的內容:

state: {
  // 使用者目前所選縣市, 預設值為 臺北市
  currCity: '臺北市',
  // 使用者目前所選行政區, 預設值為 北投區
  currDistrict: '北投區',
  // 存放 API 回傳的 縣市/行政區的列表資訊
  location: [],
  // 存放 API 回傳的所有藥局資訊
  stores: [],
},
1
2
3
4
5
6
7
8
9
10

這裏我們定義幾個狀態,分別是 currCitycurrDistrictlocationstores

前面說過,Vuex 的規定是只能從 mutations 來操作 state,所以先建立好各自對應的 mutation :

mutations: {
  setcurrCity(state, payload) {
    state.currCity = payload;
  },
  setcurrDistrict(state, payload) {
    state.currDistrict = payload;
  },
  setAreaLocation(state, payload) {
    state.location = payload;
  },
  setStores(state, payload) {
    state.stores = payload;
  },
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14

資料 API 的來源則是:

  • 縣市與行政區的列表 API : https://raw.githubusercontent.com/kurotanshi/mask-map/master/raw/area-location.json
  • 藥局資訊 API : https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json

接著,我們透過上面提供的兩個 JSON 檔案來取得資料並存入 state 裡。

actions 新增 fetchLocationsfetchPharmacies,我們透過這兩個 actions 來取得資料:

actions: {
  // 取得行政區資料
  async fetchLocations({ commit }) {  
    const json = await fetch('https://raw.githubusercontent.com/kurotanshi/mask-map/master/raw/area-location.json')
      .then((res) => res.json());

    // 透過 commit 來操作 mutations
    commit('setAreaLocation', json);
  },
  // 取得藥局資料
  async fetchPharmacies({ commit }) {  
    const json = await fetch('https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json')
      .then((res) => res.json());

    // 整理資料格式,拆出經緯度
    const data = json.features.map((d) => ({
      ...d.properties,
      latitude: d.geometry.coordinates[0],
      longitude: d.geometry.coordinates[1],
    }));

    // 透過 commit 來操作 mutations
    commit('setStores', data);
  },
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

這時就可以回到 App.vue 新增 mounted hook,並且利用上個小節介紹的 mapActions

import { mapActions } from 'vuex';
import asideMenu from './components/asideMenu.vue';

export default {
  name: 'App',
  components: {
    asideMenu,
  },
  methods: {
    ...mapActions(['fetchLocations', 'fetchPharmacies'])
  },
  mounted () {
    this.fetchLocations();
    this.fetchPharmacies();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

現在,我們可以打開瀏覽器的開發者工具確認是否有發送遠端請求:

確認是否有發送遠端請求

像上圖,我們可以透過開發者工具確認是否正確取得 area-location.jsonpoints.json。 這樣就可以在 mounted 階段分別取得行政區與藥局的資訊了。

# 將行政區套用至左側選單

取回行政區的資訊之後,下一步我們就要將這份列表套用左側的兩個下拉選單中。

打開 src/components/asideMenu.vue ,並新增 computed 以便取得我們存放在 state 內的資料。

這裏由於我們要直接將 currCitycurrDistrict 透過 v-model 指令來與下拉選單做雙向綁定, 所以需要在 computed 加上 getset

computed: {
  currCity: {
    get() {
      return this.$store.state.currCity;
    },
    set(value) {
      this.$store.commit('setcurrCity', value);
    },
  },
  currDistrict: {
    get() {
      return this.$store.state.currDistrict;
    },
    set(value) {
      this.$store.commit('setcurrDistrict', value);
    },
  }
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

並改寫 asideMenu.vue 模板內的選單部分:



 





 





<div class="wraps">
  <label>
    縣市:<select v-model="currCity">
      <option>臺北市</option>
    </select>
  </label>

  <label>
    行政區:<select v-model="currDistrict">
      <option>北投區</option>
    </select>
  </label>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13

這樣一來,當使用者更新 <select> 表單狀態時,v-model 就可以透過 computedset 將使用者所選的項目 commit 回 Vuex 的 store 了。

完成後,接著來處理 <options> 內的資料。

但此時我們發現,由於 state 內的行政區資料是這樣的階層式資料:

格式不適合 v-for 渲染

並不適合直接用 v-for 來做渲染,所以我們回到 store/index.js ,並新增 getters 來處理資料:

getters: {
  cityList(state) {
    // 城市
    return state.location.map((d) => d.name);
  },
  districtList(state) {
    // 行政區, 利用 Optional Chaining 處理預設值問題
    return state.location.find((d) => d.name === state.currCity)?.districts || [];
  },
},
1
2
3
4
5
6
7
8
9
10

這裏我們用 .map().find() 分別取出我們想要的資料。

然後就可以回到 asideMenu.vue,修改 computed 並新增 mapGetters

computed: {
  currCity: {
    // 略
  },
  currDistrict: {
    // 略
  },
  ...mapGetters(['cityList', 'districtList'])
},
1
2
3
4
5
6
7
8
9

我們就可以再次改寫選單將 v-for 加到 <option>



 





 



<label>
  縣市:<select v-model="currCity">
    <option v-for="c in cityList" :key="c">{{ c }}</option>
  </select>
</label>

<label>
  行政區:<select v-model="currDistrict">
    <option v-for="d in districtList" :key="d.id">{{ d.name }}</option>
  </select>
</label>
1
2
3
4
5
6
7
8
9
10
11

下拉選單

這個時候我們的選單就長出來了。

另外,如果我們想要在使用者更新縣市的時候,自動切換到第一個行政區時,可以在 asideMenu.vue 加入 watch :

watch: {
  districtList(v) {
    const [arr] = v;
    this.currDistrict = arr.name;
  },
},
1
2
3
4
5
6

這樣當 districtList 更新的時候,我們的 this.currDistrict 就會自動變成新的行政區列表的第一個了。

# 將藥局資料套用至左側列表

處理完縣市行政區的下拉選單後,接著來處理藥局資料。

跟剛剛一樣,我們在 asideMenu.vuecomputed 裡面取回 stores

computed: {
  currCity: {
    // 略
  },
  currDistrict: {
    // 略
  },
  ...mapState(['stores']),
  ...mapGetters(['cityList', 'districtList']),    
},
1
2
3
4
5
6
7
8
9
10

接著用 v-for 來改寫模板內列表 <li class="store-info"> 區塊:

<ul class="store-lists">
  <li class="store-info wraps" v-for="s in stores" :key="s.id">
    <h1>{{ s.name }}</h1>

    <div class="mask-info">
      <i class="fas fa-head-side-mask"></i>
      <span>大人口罩: {{ s.mask_adult }} 個</span>
    </div>

    <div class="mask-info">
      <i class="fas fa-baby"></i>
      <span>兒童口罩: {{ s.mask_child }} 個</span>
    </div>

    <div class="mask-info">
      最後更新時間: {{ s.updated }}
    </div>

    <button class="btn-store-detail">
      <i class="fas fa-info-circle"></i>
      看詳細資訊
    </button>
  </li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

這個時候,乍看之下已經有資料出現,但因為全台的藥局一共有六千多筆,整個網頁的效能會被嚴重拖垮。

藥局列表

所以我們還需要加上與縣市行政區下拉選單連動過濾的功能。

回到 store/index.js ,我們加上 getters 來處理過濾後的藥局清單:

filteredStores(state) {
  // 依縣市、行政區分組
  const { stores } = state;
  return stores.filter((d) => d.county === state.currCity && d.town === state.currDistrict);
},
1
2
3
4
5

同時, asideMenu.vue 裡面也將剛剛 computedstores 改寫成 filteredStores

computed: {
  currCity: {
    // 略
  },
  currDistrict: {
    // 略
  },
  // 移除 ...mapState(['stores'])
  // 新增 filteredStores
  ...mapGetters(['cityList', 'districtList', 'filteredStores']),
},
1
2
3
4
5
6
7
8
9
10
11

然後將 v-for="s in stores" 改成 v-for="s in filteredStores",其他部分不變:

<li class="store-info wraps" v-for="s in filteredStores" :key="s.id">
1

此時當我們切換縣市或行政區時,下方的藥局清單也會隨著同步更新了。

# 藥局列表與關鍵字搜尋連動

完成了縣市或行政區的切換後,接著來處理關鍵字的搜尋。

同樣地,我們在 store/index.js 裡的 state 新增一個 keywords 欄位:






 


state: {
  currCity: '臺北市',
  currDistrict: '北投區',
  location: [],
  stores: [],
  keywords: '',
},
1
2
3
4
5
6
7

並且加上對應的 mutations

setKeywords(state, payload) {
  state.keywords = payload;
},
1
2
3

且同樣在 asideMenu.vue 新增對應的 computed 屬性:

keywords: {
  get() {
    return this.$store.state.keywords;
  },
  set(value) {
    this.$store.commit('setKeywords', value);
  },
},
1
2
3
4
5
6
7
8

keywordsinput 輸入框用 v-model 綁定:

<div class="wraps">
  <label>
    <i class="fas fa-search-location"></i> 關鍵字搜尋:
    <input type="text" placeholder="請輸入關鍵字" v-model="keywords">
  </label>
</div>
1
2
3
4
5
6

接著改寫 vuex getters 的 filteredStores

filteredStores(state) {
  // 依縣市、行政區分組
  const { stores } = state;
  
  // 加入關鍵字判斷功能
  return state.keywords
    ? stores.filter((d) => d.name.includes(state.keywords))
    : stores.filter((d) => d.county === state.currCity && d.town === state.currDistrict);
},
1
2
3
4
5
6
7
8
9

加上 state.keywords 判斷,當使用者有輸入關鍵字的情況下,無視縣市區的分組條件,以免結果太少。

最後,增強使用者體驗,我們在 asideMenu.vue 新增一組 keywordHighlight 的 method,讓符合的關鍵字有 highlight 的效果:

methods: {
  keywordHighlight(val) {
    return val.replace(new RegExp(this.keywords, 'g'), `<span class="highlight">${this.keywords}</span>`);
  },
},
1
2
3
4
5

並將列表內的 <h1> 改寫:

<h1 v-html="keywordHighlight(s.name)"></h1>
1

然後在元件內新增 .highlight 樣式:

.highlight {
  color: #f08d49;
}
1
2
3

關鍵字highlight

# 「看詳細資訊」對話框 - lightbox.vue

首先在 src/components 目錄下新增 lightbox.vue 檔案:

(完整 lightbox.vue 檔案請參考 https://tinyurl.com/008-vuex-demo-2 (opens new window) 或是 https://gist.github.com/kurotanshi/e583f2051a5eb1ffc3191252b315bdd1 (opens new window) )

<template>
  <transition name="modal">
    <div class="modal-mask" v-show="showModal">
      <!-- 為了可以關閉燈箱,加上 @click.self="close" -->
      <div class="modal-wrapper" @click.self="close">

        <div class="modal-container">
          <div class="modal-body">
            <!-- 內容放這裡,先隨便放個 Hello -->
            <div>Hello</div>
          </div>
        </div>

      </div>
    </div>
  </transition>
</template>

<script>
export default {
  name: 'Lightbox',
  computed: {
    showModal: {
      get() {
        return this.$store.state.showModal;
      },
      set(value) {
        this.$store.commit('setshowModal', value);
      },
    },
  },
  methods: {
    close() {
      this.showModal = false;
    },
  },
};
</script>

<style scoped lang="scss">
/* 略 */
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

同樣地,我們在 vuex store 裡面新增一個 showModal 用來表示開對話框與否的狀態:

state: {
  currCity: '臺北市',
  currDistrict: '北投區',
  location: [],
  stores: [],
  keywords: '',
  showModal: false,      // 預設 false
},
1
2
3
4
5
6
7
8

並加入對應的 mutations :

setshowModal(state, payload) {
  state.showModal = payload;
},
1
2
3

完成後,我們修改 App.vue,加入 lightBox.vue

<template>
  <div id="app">
    <aside-menu />
    <div class="mask-map" id="mask-map"></div>

    <light-box />
  </div>
</template>

<script>
import { mapActions } from 'vuex';
import asideMenu from './components/asideMenu.vue';
import lightBox from './components/lightbox.vue';

export default {
  name: 'App',
  components: {
    asideMenu,
    lightBox
  },
  methods: {
    ...mapActions(['fetchLocations', 'fetchPharmacies'])
  },
  mounted () {
    this.fetchLocations();
    this.fetchPharmacies();
  }
}
</script>

<style lang="scss" src="./style.scss"></style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

因為要在側欄列表控制 Modal 的開關,所以再回到 asideMenu.vue 新增對應的 computed 屬性 showModal

showModal: {
  get() {
    return this.$store.state.showModal;
  },
  set(value) {
    this.$store.commit('setshowModal', value);
  },
},
1
2
3
4
5
6
7
8

以及相關的 methods

openInfoBox() {
  this.showModal = true;
},
1
2
3

並且在 「看詳細資訊」 按鈕加上 click 事件:

<button class="btn-store-detail" @click="openInfoBox()">
  <i class="fas fa-info-circle"></i>
  看詳細資訊
</button>
1
2
3
4

這時候點擊任一個按鈕,會看到燈箱彈出 Hello 的訊息。


再來,我們修改一下 lightBox.vue 的內容,讓每一個對話框都只出現各自的藥局資訊。

首先修改模板內 modal-body 內的 HTML:

<div class="modal-body">
  <h1 class="store-name">藥局名稱</h1>
  <hr>
  <h2 class="title">營業時間</h2>
  <table>
    <thead>
      <tr>
        <th></th>
        <th></th>
        <th></th>
        <th></th>
        <th></th>
        <th></th>
        <th></th>
        <th></th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th>早上</th>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
      </tr>
      <tr>
        <th>中午</th>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
      </tr>
      <tr>
        <th>晚上</th>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
        <td></td>
      </tr>
    </tbody>
  </table>

  <h2 class="title">地址 XXXXXXX</h2>
  <h2 class="title">電話 XXXXXXX</h2>
  <h2 class="title">備註 XXXXXXX</h2>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

對話筐介面

接下來我們要思考的是,怎麼從列表取得對應的藥局資訊並置入燈箱元件。

為此,我們在 Vuex 的 state 新增一個 infoBoxSid ,用來表示目前對話框對應的藥局 id。

state: {
  currCity: '臺北市',
  currDistrict: '北投區',
  location: [],
  stores: [],
  keywords: '',
  showModal: false,
  infoBoxSid: null,
},
1
2
3
4
5
6
7
8
9

同樣在 mutations 加上更新的方法:

setInfoBoxSid(state, payload) {
  state.infoBoxSid = payload;
},
1
2
3

接著,在 lightbox.vueasideMenu.vue 分別都加上對應的 computed 屬性:

infoBoxSid: {
  get() {
    return this.$store.state.infoBoxSid;
  },
  set(value) {
    this.$store.commit('setInfoBoxSid', value);
  },
},
1
2
3
4
5
6
7
8

改寫 asideMenu.vueopenInfoBox ,讓它可以帶入藥局 id

openInfoBox(sid) {
  this.showModal = true;
  this.infoBoxSid = sid;
},
1
2
3
4

然後是模板與事件:

<button class="btn-store-detail" @click="openInfoBox(s.id)">
  <i class="fas fa-info-circle"></i>
  看詳細資訊
</button>
1
2
3
4

同時,在 lightbox.vuecomputed 加上,就可以取得對應藥局的詳細資訊。

currStore() {
  return this.$store.state.stores.filter((d) => d.id === this.infoBoxSid)[0];
},
1
2
3

藥局資訊

其中,藥局的營業時間存放在 service_periods 這個欄位。

為此,我們在 lightbox.vuecomputed 加上 servicePeriods 將一連串的字串拆成我們所需的內容

servicePeriods() {
  let servicePeriods = this?.currStore?.['service_periods'] || '';
  servicePeriods = servicePeriods.replace(/N/g, 'O').replace(/Y/g, 'X');

  return servicePeriods
    ? [servicePeriods.slice(0, 7).split(''), 
       servicePeriods.slice(7, 14).split(''), 
       servicePeriods.slice(14, 21).split('')]
    : servicePeriods;
},
1
2
3
4
5
6
7
8
9
10

並修改燈箱內模板表格 <tbody> 部分,使用 v-for 來渲染:

<tbody>
  <tr>
    <th>早上</th>
    <td v-for="(s, idx) in servicePeriods[0]" :key="idx">{{s}}</td>
  </tr>
  <tr>
    <th>中午</th>
    <td v-for="(s, idx) in servicePeriods[1]" :key="idx">{{s}}</td>
  </tr>
  <tr>
    <th>晚上</th>
    <td v-for="(s, idx) in servicePeriods[2]" :key="idx">{{s}}</td>
  </tr>
</tbody>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

與藥局相關資訊:

<!-- 
  將 <h1 class="store-name">藥局名稱</h1> 換成這個
-->
<h1 class="store-name">{{ currStore.name }}</h1>
1
2
3
4
<!-- 
  將
    <h2 class="title">地址 XXXXXXX</h2>
    <h2 class="title">電話 XXXXXXX</h2>
    <h2 class="title">備註 XXXXXXX</h2>
  換成這個
-->
<h2 class="title">地址: {{ currStore.address }}</h2>
<h2 class="title">電話: {{ currStore.phone }}</h2>
<h2 v-if="currStore.custom_note" class="title">備註: {{ currStore.custom_note }}</h2>
1
2
3
4
5
6
7
8
9
10

到此,對話框的部分就完成了。

若是因為一開始沒有 currStore 的情況下找不到 currStore.name,導致 console 主控台會出現警告,

錯誤訊息

此時我們可以在 modal 加上 v-if 來繞過即可 <div class="modal-body" v-if="currStore">

# 藥局地圖 - maskMap.vue

最後是右側地圖的部分,書中範例使用的是 Leaflet JS 與開放街圖 OpenStreetMap 做搭配。

Leaflet API 可參考: https://leafletjs.com/reference-1.7.1.html (opens new window)

Leaflet

首先回到終端機,按下 ctrl + c 來結束 yarn 或 npm script。

然後執行下面指令來安裝 leaflet 的相關套件

$ npm install leaflet 

# 或 yarn add leaflet 
1
2
3

再來同樣在 components/ 目錄下新增 maskMap.vue 檔案:

<template>
  <div class="mask-map" id="mask-map"></div>
</template>

<script>
export default {
  name: 'maskMap',
};
</script>
1
2
3
4
5
6
7
8
9

同樣將 maskMap.vue import 到 App.vue, 並將模板裡的 <div class="mask-map" id="mask-map"></div> 替換掉:

<!-- App.vue -->
<template>
  <div id="app">
    <aside-menu />
    <maskMap />
    <light-box />
  </div>
</template>

<script>
import { mapActions } from 'vuex';
import asideMenu from './components/asideMenu.vue';
import lightBox from './components/lightbox.vue';
import maskMap from './components/maskMap.vue'; 

export default {
  name: 'App',
  components: {
    asideMenu,
    lightBox,
    maskMap
  },
  // 以下略..
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

接著回到 maskMap.vue , 修改 <script> 將 leaflet import 進來,並在 mounted 階段啟動地圖:

import L from 'leaflet';

export default {
  name: 'maskMap',
  data() {
    return {
      // 因為別的地方用不到 map ,所以不需要丟到 vuex
      map: {},
    };
  },
  mounted() {
    
    // 以下動作將地圖初始化
    this.map = L.map('mask-map', {
      center: [25.03, 121.55],
      zoom: 14,
    });

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: '<a target="_blank" href="https://www.openstreetmap.org/">© OpenStreetMap 貢獻者</a>',
      maxZoom: 18,
    }).addTo(this.map);

  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

地圖顯示在畫面上

這個時候應該會看到一張地圖出現在右側了。


地圖出現之後,再來我們要完成地圖與左側選單的連動。

也就是當使用者在下拉選單改變了行政區,我們希望讓地圖自動切換到指定的位置。 要達成這個目標,首先我們要先知道目前所選行政區的經緯度資訊。

我們只要在 vuex 透過 state.currDistrictgetters.districtList 來比對就可以得到了, 此時在 getters 加上:

currDistrictInfo(state, getters) {
  // 目前所選行政區資訊
  return getters.districtList.find((d) => d.name === state.currDistrict) || {};
},
1
2
3
4

同樣地在 maskMap.vue 加上 computed 來把 getters 拿回來:

computed: {
  currDistrictInfo() {
    return this.$store.getters.currDistrictInfo;
  },
},
1
2
3
4
5

就可以取得對應的資訊。

這時候,當使用者切換行政區時,我們可以加個 watch 來進行監測:

watch: {
  // 切換行政區
  currDistrictInfo(dist) {
    // this.map.panTo() 可以指定地圖中心點
    this.map.panTo(new L.LatLng(dist.latitude, dist.longitude));
  },
},
1
2
3
4
5
6
7

並在此時透過 Leaflet 所提供的 this.map.panTo 來指定地圖中心點。

完成了地點連動的功能,再來就是插入藥局的 marker 標記。 同樣也要取得藥局的列表資訊,還好我們之前在 vuex 已經做過了,所以這裡只需要在 maskMap.vue 加入 computed

// 也可以使用 mapGetters
computed: {
  currDistrictInfo() {
    return this.$store.getters.currDistrictInfo;
  },
  filteredStores() {
    return this.$store.getters.filteredStores;
  },
},
1
2
3
4
5
6
7
8
9

然後在 watch 新增 filteredStores,當列表變動時透過 addMarker 來增加標記到地圖上。

watch: {
  currDistrictInfo(dist) {
    // 切換行政區指定地圖中心點
    this.map.panTo(new L.LatLng(dist.latitude, dist.longitude));
  },
  filteredStores(stores) {
    // 根據藥局資訊加上對應 marker
    stores.forEach((element) => this.addMarker(element));
  },
},
1
2
3
4
5
6
7
8
9
10

最後是 addMarker 的部分,我們把它放在 methods 裡面:

addMarker(item) {  
  // 標記的圖示,可自行替換參數
  const ICON = {
    iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-violet.png',
    shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
    iconSize: [25, 41],
    iconAnchor: [12, 41],
    popupAnchor: [1, -34],
    shadowSize: [41, 41],
  };

  // 將標記放置到地圖上
  const marker = L.marker([item.longitude, item.latitude], ICON)
    .addTo(this.map)
    .bindPopup(`<h2 class="popup-name">${item.name}</h2>`);
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

這個時候標記就會出現在地圖上了。

顯示標記

嘿嘿,別高興太早,當我們不斷切換下拉選單的行政區時,你會發現標記數量不會清掉,而是越來越多。

這時就需要在 methods 加上 clearMarkers 方法來清除地圖上的標記:

clearMarkers() {
  // 清除地圖所有標記  
  this.map.eachLayer((layer) => {
    if (layer instanceof L.Marker) {
      this.map.removeLayer(layer);
    }
  });
},
1
2
3
4
5
6
7
8

並且改寫 watch filteredStores 內的任務:

filteredStores(stores) {
  // 先清除原有 marker
  this.clearMarkers();

  // 根據藥局資訊加上對應 marker
  stores.forEach((element) => this.addMarker(element));
},
1
2
3
4
5
6
7

直到目前為止,我們已經完成了大部分的工作了。


最後,假設我想知道列表中的某間藥局位置,可以做到嗎? 可以的!

首先在 data 新增 markers 陣列,用來存放所有標記資訊:

data () {
  return {
    // 因為別的地方用不到 map ,所以不需要丟到 vuex
    map: {},
    markers: [],
  };
},
1
2
3
4
5
6
7

然後改寫前面的 addMarker

addMarker(item) {  
  
  // ...原本的內容不動,因篇幅有限省略...
  
  // 替 marker 加入 id 與經緯度資訊
  marker.markerId = item.id;
  marker.lng = item.longitude;
  marker.lat = item.latitude;
  
  // 將 marker push 到陣列裡
  this.markers.push(marker);
},
1
2
3
4
5
6
7
8
9
10
11
12

最後在 clearMarkers 的時候要記得清空陣列:

clearMarkers() {

  // ...原本的內容不動,因篇幅有限省略...

  // 加上清空陣列
  this.markers.length = 0;
},
1
2
3
4
5
6
7

並且在 maskMap.vuemethods 加上 triggerPopup 這個方法:

triggerPopup(markerId) {
  // 找出目標標記
  const marker = this.markers.find((d) => d.markerId === markerId);

  // 將地圖中心指向目標標記,並開啟 Popup
  this.map.flyTo(new L.LatLng(marker.lng, marker.lat), 15);
  marker.openPopup();
},
1
2
3
4
5
6
7
8

接著回到 asideMenu.vue<li class="store-info wraps"> 加上 click 事件。

這時問題來了,triggerPopup 寫在 maskMap.vue 身上,要如何從 asideMenu.vue 跨元件觸發 triggerPopup 呢 ?

Vuex 雖然可以幫助我們管理共同資料,但是卻沒有統一的事件控管。

此時,我們可以利用事件傳遞的方式,來觸發跨元件的 method

首先打開 App.vue,並在 aside-menumask-map 分別加上 ref 別名:

<template>
  <div id="app">
    <aside-menu @triggerMarkerPopup="openPopup" ref="menu" />
    <mask-map ref="map" />
    <light-box />
  </div>
</template>
1
2
3
4
5
6
7

並在 aside-menu 加上自訂的事件 triggerMarkerPopup

同時 App.vue 也新增 openPopup method。 此時唯一要做的就是透過 this.$refs.map 去執行對應 map 的 triggerPopup

methods: {
  ...mapActions(['fetchLocations', 'fetchPharmacies']),
  openPopup(id) {
    this.$refs.map.triggerPopup(id);
  },
},
1
2
3
4
5
6

再回到 asideMenu.vue,將原本的

<li class="store-info wraps" v-for="s in filteredStores" :key="s.id">
1

改寫成

<li class="store-info wraps"
    v-for="s in filteredStores" :key="s.id" @click="$emit('triggerMarkerPopup', s.id)">
1
2

透過 $emit 的方式觸發事件,來達到跨元件 method 的呼叫。 這樣,我們的口罩地圖就完成了!

小提醒

若讀者在開發過程中有任何問題,完整的範例原始碼可參考這個 repo: https://github.com/kurotanshi/mask-map-demo (opens new window)

Last Updated: 1/27/2021, 1:31:20 AM