# Vue 出一個電商網站 ## 一、啟用一個 Vue Cli 並且 引用帶入專屬 API ### 前置作業: 1. 安裝 Vue Cli:`npm install -g vue-cli` (注意這是 2.x 版本) 啟用:`vue init webpack my-project` 2. 安裝 vue-axios:`npm install --save axios vue-axios` 注意若你是使用 Vue.js 2.x ,vue-axios 必須使用 2.x 舊版,請在 `package.json` 中 `dependencies` 移除 "vue-axios",並安裝 2.x 版本:`npm install --save vue-axios@2` 3. 如有需要加入 StandardJS... ### 建議將 API 網址存入環境變數: 1. `config/dev.env.js` 配置 : ```js 'use strict' const merge = require('webpack-merge') const prodEnv = require('./prod.env') module.exports = merge(prodEnv, { NODE_ENV: '"development"', // 下面兩個為自定義,記得要單引號包雙引號!!!! APIPATH: '"https://vue-course-api.hexschool.io"', CUSTOMPATH: '"weitsai"' }) ``` 注意!加入環境變數須重啟 `npm run dev`,正式上線時需把環境變數也加進 `config/prod.env.js` 中 2. `App.vue` 中測試看看將 API 存入環境變數中能不能使用 vue-axios 發送 Ajax: ```html <script> export default { name: 'App', created() { // const api = 'https://vue-course-api.hexschool.io/api/weitsai/products' const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/products` this.$http.get(api).then((response) => { console.log(response) }) }, } </script> ``` ## 二、引用 Bootstrap 套件,並客製化樣式 ### 前置作業: 1. 安裝 bootstrap:`npm install bootstrap --save` 2. 回 `App.vue` 在 `<style>` 標籤新增 `lang` 屬性,值為 "scss": ```html <style lang="scss"> @import "~bootstrap/scss/bootstrap"; </style> ``` 若在此步驟發生錯誤,應該是未安裝 node-sass 及 sass-loader。 安裝:`npm install node-sass sass-loader` 若還是報錯,應該是版本不符的關係,請把 node-sass 降為 4.x 版,sass-loader 降為 7.x 版。 修正: `package.json` 的 "dependencies" 記得移除該兩項目並再安裝:`npm install sass-loader@7 node-sass@4` ### 客製化樣式前置作業: 1. 在 `src/assets` 新增一個 `all.scss` 配置: ```scss @import "~bootstrap/scss/functions"; @import "./helpers/variables"; @import "~bootstrap/scss/bootstrap"; ``` 2. 記得 `App.vue` 中 import 也要改路徑: ```html <style lang="scss"> @import "./assets/all.scss"; </style> ``` 3. 在 `node_modules/bootstrap `中找到 `_variables.scss` 複製一份到 `scs/assets/helpers`,helpers 資料夾為自行創建,新增此路徑是為了避免與原始套件路徑衝突。 ### 客製化: 修改 `src/assets/helpers/_variables.scss` 即可。 #### 補充 注意!若在 `<style>` 標籤上加入 `scoped` 代表此段 CSS 會被封裝在該元件內,只會在該元件上有作用! ## 三、製作登入頁面 1. `src/components` 新增 `pages` 資料夾,`src/components/pages` 新增 `Login.vue`,內容自訂 2. 回 `router/index.js` ,import Login.vue: ```js import Vue from 'vue' import Router from 'vue-router' import HelloWorld from '@/components/HelloWorld' import Login from '@/components/pages/Login' // 新增 Vue.use(Router) export default new Router({ routes: [ { path: '/', name: 'HelloWorld', component: HelloWorld }, // 新增 { path: '/login', name: 'Login', component: Login } ] }) ``` 3. 直接拷貝一個 bootstrap 的 [登入畫面](https://getbootstrap.com/docs/4.3/examples/sign-in/),右鍵檢視原始碼複製貼上到 `Login.vue` 的 `<template>` 裡面(記得最外層要用一個 div 包起來),可一併複製原始碼中的 CSS: `<link href="signin.css" rel="stylesheet">` 4. 在 `Login.vue` 定義資料結構: ```html <script> export default { name: 'Login', data() { return { user: { username: '', password: '' } } } } </script> ``` ### 登入功能 (`Login.vue`) + 在輸入帳號及密碼的 input 標籤綁上 `v-model`,分別為:`v-model="user.username"` 及 `v-model="user.password"` + 直接在 `<form>` 標籤上綁定 `@submit.prevent="signin"` 事件 ```js methods: { signin() { const api = `${process.env.APIPATH}/admin/signin` const vm = this // 將 vm.user 傳送給後端 vm.$http.post(api, vm.user).then((response) => { console.log(response.data) // 如果驗證成功後端會傳來 token if (response.data.success) { const token = response.data.token const expired = response.data.expired console.log(token, expired) // 將 token 寫入 Cookie document.cookie = `hexToken=${token}; expires=${new Date(expired)}` vm.$router.push('/admin') } }) } }, ``` #### 補充 因為瀏覽器的同源政策,後端無法透過 set-cookie 將 Cookie 寫入,所以這邊調整為後端將 token 送來並由前端寫入 Cookie,再根據 Axios 文件,把 Cookie 加入 `this.$http.defaults.headers.common.Authorization = myCookie;`,這一段會套用至所有 API 所以只需要寫一遍。 讀取 Cookie 寫法: ```js document.cookie = "test2=World"; const cookieValue = document.cookie .split('; ') .find(row => row.startsWith('test2')) .split('=')[1]; ``` 寫入 Cookie 寫法: ```js document.cookie = "Cookie名稱=Cookie的值; expires=Fri, 31 Dec 9999 23:59:59 GMT"; ``` 在 `Dashboard.vue` (後台)要將 Cookie 取出並將 Cookie 帶入預設傳送的 header 中: ```js export default { components: { Sidebar, Navbar, AlertMessage }, created () { const myCookie = document.cookie .split('; ') .find((row) => row.startsWith('hexToken')) .split('=')[1] this.$http.defaults.headers.common.Authorization = myCookie } } ``` ### 登出功能 ```html <template> <div> <a href="#" @click.prevent="signout">Sign out</a> </div> </template> <script> export default { name: 'HelloWorld', data () { return { msg: 'Welcome to Your Vue.js App' } }, methods: { signout () { const api = `${process.env.APIPATH}/logout` const vm = this this.$http.post(api).then((response) => { console.log(response.data) if (response.data.success) { // 如果成功登出就導向登出頁面 vm.$router.push('/login') } }) } }, } </script> ``` ### Q&A Q:為什麼 sign out 是用 post 發送 Ajax? A: ```txt AJAX 的 HTTPMethods 是一種規範,如同 MDN 文件提到 「GET 方法請求展示指定資源。使用 GET 的請求只應用於取得資料。」 「POST 方法用於提交指定資源的實體,通常會改變伺服器的狀態或副作用(side effect)。」 登入和登出都是改變狀態,因此要使用 POST 方法 ``` ## 四、驗證登入及 Vue Router 的配置 ### 驗證登入功能前置說明: #### [路由信息](https://router.vuejs.org/zh/guide/advanced/meta.html) 定義路由的時候可以配置 `meta` 字段: ```js export default new Router({ routes: [ { path: '/', name: 'HelloWorld', component: HelloWorld, meta: { requiresAuth: true } } ] }) ``` 若一個路由有 `meta` 屬性且配置 `{ requiresAuth: true }`,代表從其他頁面切換到此路由需要經過認證。驗證手續可透過 [導航守衛](https://router.vuejs.org/zh/guide/advanced/navigation-guards.html#%E5%85%A8%E5%B1%80%E5%90%8E%E7%BD%AE%E9%92%A9%E5%AD%90),說明如下: #### 導航守衛 導航守衛會在切換頁面時觸發,以下為全局前置守衛語法: ```js const router = new VueRouter({ ... }) router.beforeEach((to, from, next) => { // ... }) ``` - **`to: Route`**:即將要進入的目標[路由對象](https://router.vuejs.org/zh/api/#路由对象) - **`from: Route`**:當前導航正要離開的路由 - **`next: Function`**:一定要調用該方法來 **resolve** 這個鉤子。執行效果依賴 `next` 方法的調用參數。 - **`next()`**:進行管道中的下一個鉤子。如果全部鉤子執行完了,則導航的狀態就是 **confirmed** (確認的)。 - **`next(false)`**:中斷當前的導航。如果瀏覽器的URL改變了(可能是用戶手動或者瀏覽器後退按鈕),那麼 URL 地址會重置到`from `路由對應的地址。 - **`next('/')` 或者 `next({ path: '/' })`**:跳轉到一個不同的地址。當前的導航被中斷,然後進行一個新的導航。你可以向`next `傳遞任意位置對象,且允許設置諸如 `replace: true`、`name: 'home'`之類的選項以及任何用在 [`router-link`的`to`prop](https://router.vuejs.org/zh/api/#to)或 [`router.push`](https://router.vuejs.org/zh/api/#router-push) 中的選項。 - **`next(error)`**:(2.4.0+) 如果傳入 `next `的參數是一個 `Error` 實例,則導航會被終止且該錯誤會被傳遞給 [`router.onError()`](https://router.vuejs.org/zh/api/#router-onerror)註冊過的回調。 ### 驗證功能: 1. 回 `router/index.js` ,替路由 `path: '/'` 配置 `meta: { requiresAuth: true }`,代表從其他頁面切換到此路由是需要經過驗證的: ```js export default new Router({ routes: [ { path: '*', redirect: '/login' }, { path: '/', name: 'HelloWorld', component: HelloWorld, meta: { requiresAuth: true } }, { path: '/login', name: 'Login', component: Login } ] }) ``` #### 補充 ```js { path: '*', redirect: '/login' }, ``` 此段程式碼是當使用者自己輸入其他不存在路徑會強制轉回登入頁面。 2. 回 `main.js` 配置導航守衛: ```js router.beforeEach((to, from, next) => { // 如果到達頁面的路由配有 meta.requiresAuth 值為 true 就代表需要通過驗證 if (to.meta.requiresAuth) { // 此 API 為檢查用戶是否仍持續登入 六角學院提供 const api = `${process.env.APIPATH}/api/user/check` axios.post(api).then((response) => { console.log(response.data) // 如果確定為持續登入狀態 if (response.data.success) { next() // next() 代表同意放行到 to 的路由位址 } else { next('/login') // 不是登入狀態就轉到登入頁面 } }) } else { next() // 如果到達頁面的路由沒有配有 meta.requiresAuth 或值為 false 就直接放行 } }) ``` #### 補充 這邊發送 Ajax 是使用 ` axios.post(api)` 而不是 `this.$http.post(api)` 是因為該段程式碼在 `router.beforeEach()` 的執行環境內,而 `this.$http.post(api)` 只能在元件內使用,所以改用 axios 套件用法。 ### Q&A Q:為什麼導航守衛 `router.beforeEach()` 是寫在 `main.js` 而不是 `index.js`,官方的教學影片也是設定在 `index.js `中,兩者有甚麼差別嗎? A:導航守衛寫在 `router/index.js` 和 `main.js` 都是可行的。 ## 五、套用 Bootstrap Dashboard 版型 1. 在 `src/components` 新增 `Dashboard.vue`,直接拷貝 [bootsrap Dashboard 版型](view-source:https://getbootstrap.com/docs/4.3/examples/dashboard/),右鍵檢視原始碼複製貼上到 `<template>` 2. 複製該版型之 CSS ` <link href="dashboard.css" rel="stylesheet">` 的內容,在 `src/assets` 新增 `dashboard.scss` 並貼上,在 `src/assets/all.scss` 匯入 `dashboard.scss`: ```scss @import "~bootstrap/scss/functions"; @import "./helpers/variables"; @import "~bootstrap/scss/bootstrap"; @import "./dashboard.scss"; // 新增 ``` 3. 回 `router/index.js` 匯入 `Dashboard.vue`: ```js import Vue from 'vue' import Router from 'vue-router' import HelloWorld from '@/components/HelloWorld' import Login from '@/components/pages/Login' import Dashboard from '@/components/Dashboard' // 新增 Vue.use(Router) export default new Router({ routes: [ { path: '*', redirect: '/login' }, { path: '/', name: 'HelloWorld', component: HelloWorld, meta: { requiresAuth: true } }, { path: '/login', name: 'Login', component: Login }, { // 新增 path: '/admin', name: 'Dashboard', component: Dashboard, meta: { requiresAuth: true } }, ] }) ``` #### 補充 將 `Dashboard.vue` 拆解成多個小元件,分別有 `Navbar.vue` 和 `Sidebar.vue`: `Dashboard.vue` 內容: ```html <template> <div> <Navbar></Navbar> <div class="container-fluid"> <div class="row"> <Sidebar></Sidebar> <main role="main" class="col-md-9 ml-sm-auto col-lg-10 px-4"> </main> </div> </div> </div> </template> <script> import Navbar from './Navbar' import Sidebar from './Sidebar' export default { components: { Sidebar, Navbar } } </script> ``` ### Q&A ##### 一、 Q:為什麼要把 `Dashboard.vue` 的網頁內容再拆分成 `navbar` 和 `sidebar` 兩元件呢? A:拆分 sidebar、navbar 可以有較多的彈性,若其他地方有需要 sidebar、navbar 可直接引入,就不用重複寫該區域內容。 ##### 二、 Q:為什麼 `Navbar.vue` 、`Sidebar.vue` 不用 export 出去? ```js export default { // ... } ``` A:這段是由 Vue Cli 封裝 `*.vue` 的檔案,而 export 是針對 JS 語法的導出,如果沒有 JS ( `Navbar.vue` 和 `Sidebar.vue` 中只有 `<template>` 標籤而已)就當作為 template 使用。 ##### 補充 Vue Cli 是基於 Webpack 開發的,而 .vue 檔案是透過 vue-loader 的方式載入,它會解析檔案中的內容並匯出,在下方的文件中可以看到 [“使用 webpack loader 将 `<style>` 和 `<template>` 中引用的资源当作模块依赖来处理”,]([https://vue-loader.vuejs.org/zh/#vue-loader-%E6%98%AF%E4%BB%80%E4%B9%88%EF%BC%9F](https://vue-loader.vuejs.org/zh/#vue-loader-是什么?)) 它會自動將 `<template>` 和 `<style>` 轉成 JS,因此 `<style>` 與 `<template>` 就不需要另外的 export 語法囉。 ##### 三、 Q:為什麼 `Dashboard.vue` import 了 `Navbar.vue` 和 `Sidebar.vue` 卻又要 export 他們? A:因為 webpack 所以內容最後都會回到 `main.js`,因此在 `Dashboard.vue` import 元件使用,最終都還是需要用 export 來給 `main.js` 編譯。 5. 在 `src/components/pages` 下新增 `Products.vue`,這邊以巢狀路由的方式呈現 `Products.vue` 6. 回 `router/index.js` 配置: ```js import Vue from 'vue' import Router from 'vue-router' import HelloWorld from '@/components/HelloWorld' import Login from '@/components/pages/Login' import Dashboard from '@/components/Dashboard' import Products from '@/components/pages/Products' // 記得 import Vue.use(Router) export default new Router({ routes: [ // ...略 { path: '/admin', // name: 'Dashboard', component: Dashboard, children: [ { path: '', // 這邊設置預設路徑,記得父層若有 name 屬性會有警告 name: 'Products', component: Products, meta: { requiresAuth: true }, // meta 調整到 chuldren 這邊 }, ] }, ] }) ``` 7. 回 `Dashboard.vue` 補上 `<routerView>`: ```html <template> <div> <Navbar></Navbar> <div class="container-fluid"> <div class="row"> <Sidebar></Sidebar> <main role="main" class="col-md-9 ml-sm-auto col-lg-10 px-4"> <router-view></router-view> <!-- 新增 --> </main> </div> </div> </div> </template> ``` ## 六、製作產品列表 1. 調整一下路徑,`HelloWorld.vue` 沒有用到,所以在 `router/index.js` 刪除路由及 import 2. 還記得製作登入頁面 (`Login.vue`) 時,後端會傳來一組 token 嘛,我們將這組 token 寫入 Cookie。現在我們在 `Dashboard.vue` 中的巢狀路由 `Products.vue` 裡面要取得 品列表,而管理者取得商品的這個 API `/api/:api_path/admin/products/all`,是需要透過 token 來驗證的,所以要把這個 token 傳到後端才能取得商品列表: ```html <!-- in Dashboard.vue --> <script> import Navbar from './Navbar' import Sidebar from './Sidebar' export default { components: { Sidebar, Navbar }, created() { // 把 Cookie 取出來 const myCookie = document.cookie .split('; ') .find(row => row.startsWith('hexToken')) .split('=')[1] // 透過 Axios 當發送 Ajax 就傳入帶有 Authorization 這個 header 的請求 this.$http.defaults.headers.common.Authorization = myCookie }, } </script> ``` 3. `Products.vue` 取得商品列表: ```html <template> <div> <div class="text-right mt-2"> <button class="btn btn-primary">建立新產品</button> </div> <table class="table mt-2"> <thead> <th width="120px">分類</th> <th>產品名稱</th> <th width="120">原價</th> <th width="120">售價</th> <th width="100">是否啟用</th> <th width="80">編輯</th> </thead> <tbody> <tr v-for="item in products" :key="item.id"> <td>{{ item.category }}</td> <td>{{ item.title }}</td> <td class="text-right">{{ item.origin_price }}</td> <td class="text-right">{{ item.price }}</td> <td> <span v-if="item.is_enabled" class="text-success">啟用</span> <span v-else>未啟用</span> </td> <td> <button class="btn btn-outline-primary btn-sm">編輯</button> </td> </tr> </tbody> </table> </div> </template> <script> export default { data() { return { products: [],// 或空物件 {} 都 ok } }, methods: { getProducts() { const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/products/all` const vm = this this.$http.get(api).then((response) => { // console.log(response) vm.products = response.data.products }) } }, created() { this.getProducts() }, } </script> ``` ## 七、Vue 中運用 Bootstrap 及 jQuery 1. 在 `main.js` 匯入 bootstrap 套件: ```js import Vue from 'vue' import axios from 'axios' import vueAxios from 'vue-axios' import 'bootstrap' // 通常 import 這邊分上下兩部分,上面都是 npm 的套件 import App from './App' import router from './router' // 以下略... ``` 這時如果有通報以下錯誤: ```txt These dependencies were not found: * jquery in ./node_modules/bootstrap/dist/js/bootstrap.js * popper.js in ./node_modules/bootstrap/dist/js/bootstrap.js ``` 這時候請先關閉 `npm run dev` 的狀態並安裝:`npm install --save jquery popper.js` ### Q&A Q:為什麼還要再 `main.js` 匯入 bootstrap 套件,在 `App.vue` 不是已經匯入 `all.scss`(包含 `bootstrap.scss`)了? A:關於 Bootstrap 有分為 JS 和 CSS 部分,JS 部分就是在 `main.js` 使用。而 Bootstrap 團隊有針對 Webpack 去優化,因此只要附上 import 'bootstrap' 就可以抓到下載的 BS 資源。 2. 複製 bootsrap 的 modal 模板到 `Products.vue` 裡面,然後在新增產品的按鈕上加上點擊事件,點擊就讓 modal 出現: ```html <script> import $ from 'jquery' // ...略 methods: { openModal() { $('#productModal').show() } }, // ...略 </script> ``` 記得使用 jQuery 之前要 `import $ from 'jquery'` ,不然元件會無法認得 $ 符號,至於 bootstrap 的 modal 的相關用法 可參考[官方 methods](https://getbootstrap.com/docs/4.3/components/modal/#methods)。 ## 八、產品的新增修改 1. Modal 模板可參考 [課程部分模板](https://github.com/hexschool/vue-course-api-wiki/wiki/%E8%AA%B2%E7%A8%8B%E9%83%A8%E5%88%86%E6%A8%A1%E6%9D%BF) 2. 在 `Product.vue` 新增 `tempProduct` 資料,這筆資料是用來存放即將出送給後端的商品資訊,有可能是建立新產品的資料,也有可能是修改舊產品的資料: ```js data() { return { products: [], tempProduct: {}, // 新增 } }, ``` 觀察**新增產品**的 API 文件: ```txt [API]: /api/:api_path/admin/product [方法]: post [參數]: { "data": { "title": "[賣]動物園造型衣服3", "category": "衣服2", "origin_price": 100, "price": 300, "unit": "個", "image": "test.testtest", "description": "Sit down please 名設計師設計", "content": "這是內容", "is_enabled": 1, "imageUrl": <<firebase storage url>> } } [成功回應]: { "success": true, "message": "已建立產品" } ``` 依據 API 文件在 modal 中的 `input` 標籤掛上對應欄位的 `v-model`: ```html <!-- 僅提供部分範例 --> <div class="form-group"> <label for="description">產品描述</label> <textarea v-model="tempProduct.description" type="text" class="form-control" id="description" placeholder="請輸入產品描述"></textarea> </div> <div class="form-group"> <label for="content">說明內容</label> <textarea v-model="tempProduct.content" type="text" class="form-control" id="content" placeholder="請輸入產品說明內容"></textarea> </div> <div class="form-group"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="is_enabled" v-model="tempProduct.is_enabled" :true-value="1" :false-value="0"> <label class="form-check-label" for="is_enabled"> 是否啟用 </label> </div> </div> ``` 特別注意 `"is_enabled"` 是否啟用欄位的值是 0 和 1,並不是原本 `v-model` 設置的 true 和 false,所以要額外設置 `:true-value="1"` 和 `:false-value="0"`。 3. 在 modal 中的確認按鈕加上 updateProduct 事件: ```html <div class="modal-footer"> <button type="button" class="btn btn-outline-secondary" data-dismiss="modal">取消</button> <button type="button" class="btn btn-primary" @click="updateProduct">確認</button> </div> ``` 新增 updateProduct 事件之後,思考一下,其實點擊新增產品按鈕跟點擊編輯產品的按鈕後所彈出的 modal 需要的 `input` 欄位都是一樣的,所以新增產品跟編輯產品可以共用這個 modal,都在 updateProduct 事件判斷完後處理 4. 觸發 `openModal()` 時判斷是要新增產品還是修改舊產品: + 新增 isNew 資料,值在這個時候是什麼沒有影響,這邊先寫入為 false ```js data() { return { products: [], tempProduct: {}, isNew: false, } }, ``` **注意!** `openModal()` 傳入的 isNew 參數跟 data 中的 this.isNew 是不同的東西,只是名稱相同: ```js openModal(isNew, item) { // 如果是新產品的話就... if (isNew) { this.tempProduct = {} // 把 tempProduct 清空藉由 v-model 管理者自己寫入新產品內容 this.isNew = true } else { // 如果是舊產品就把當前的 item 寫到 this.tempProduct 裡面 this.tempProduct = Object.assign({}, item) // this.tempProduct = item 不要這樣寫,請看補充說明2 // this.tempProduct = {...item} 其實利用 ES7 的展開屬性也能達到跟 Object.assign({}, item) 一樣的目的 this.isNew = false } $('#productModal').modal('show') }, ``` ### 補充說明 `openModal()` 傳入的 isNew 是自主傳入 true 或 false,如果是在新增產品的按鈕上就是傳 true,如果是在編輯產品的按鈕上就是傳 false,這樣就能區分到底是新產品還是舊產品: ```html <div class="text-right mt-2"> <button class="btn btn-primary" @click="openModal(true)">建立新產品</button> </div> <!-- 中間略... --> <td> <button class="btn btn-outline-primary btn-sm" @click="openModal(false, item)">編輯</button> </td> ``` ### 補充說明 2 當觸發 `openModal()` 的時候,如果是舊產品就把當前 item 寫入 this.tempProduct,但是為什麼要不能寫成 `this.tempProduct = item`? 因為物件傳參考的特性, `this.tempProduct = item` 這行程式碼實際是會讓 `this.tempProduct` 和 `item` 指向同一個記憶體位,又因為 `v-model` 是掛在 `this.tempProduct` 上面,所以當你更改 modal 裡面 `input` 標籤的值, `v-model` 開始作用, `this.tempProduct` 中的值被更改了,**但是!**連動著產品列表的該 item 中的值也會被改到。 這種寫法的缺點在於當你打開 modal 之後修改裡面的 `input` 值,修改完後按下 *取消* 按鈕,畫面中的該 item 的內容是會呈現被修改完的版本,但明明是按下取消的狀態。 解決方法為使用 ES6 的 `Object.assign()` ,簡言之就是它可以複製一個物件的值(或者合併物件),但可以讓這個被複製出來的新物件指向新的記憶體位址。(此段落為個人見解,若理解錯誤請不吝指正!謝謝) `Object.assign()` 語法: ```js Object.assign(target, ...sources) ``` 範例: ```js var o1 = { a: 1 }; var o2 = { b: 2 }; var o3 = { c: 3 }; var obj = Object.assign(o1, o2, o3); console.log(obj); // { a: 1, b: 2, c: 3 } console.log(o1); // { a: 1, b: 2, c: 3 }, 目標物件本身也被改變。 console.log(obj === o1) // true ``` 5. 送出產品資料給後端,製作 updateProduct 事件: 新增產品跟修改產品是用兩個不同的 API 所以要另增判斷式 ```js updateProduct () { let api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/product` let httpMethod = 'post' const vm = this // 藉由 openModal 事件已經讓 vm.isNew 有真假值 if (!vm.isNew) { // 如果是舊產品就用修改產品的 API,並且要用 put http method api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/product/${vm.tempProduct.id}` httpMethod = 'put' } // 把 vm.tempProduct 傳給後端,但要按照 API 文件的格式要求 this.$http[httpMethod](api, {data: vm.tempProduct}).then((response) => { console.log(response.data); if (response.data.success) { $('#productModal').modal('hide') // 成功後要重新載入全部產品 vm.getProducts() } else { $('#productModal').modal('hide') vm.getProducts() console.log('新增或修改失敗!'); } }) } ``` ### 產品的刪除功能(這個段落為自己練習) 1. 在 `<template>` 中加入刪除按鈕和 *打開刪除 modal* 事件: ```html <td class="d-flex"> <button class="btn btn-outline-primary btn-sm" @click="openModal(false, item)">編輯</button> <button class="btn btn-outline-danger btn-sm ml-1" @click="openDeleteModal(item)">刪除</button> </td> ``` 2. `openDeleteModal()` 事件: ```js openDeleteModal(item) { this.tempProduct = { ...item } $('#delProductModal').modal('show') }, ``` 3. 在 `#delProductModal` 中的確認刪除按鈕加上 `deleteProduct()` 事件: ```html <div class="modal-body"> 是否刪除 <strong class="text-danger">{{ tempProduct.title }}</strong> 商品(刪除後將無法恢復)。 </div> <div class="modal-footer"> <button type="button" class="btn btn-outline-secondary" data-dismiss="modal">取消</button> <button type="button" class="btn btn-danger" @click="deleteProduct">確認刪除</button> </div> ``` 4. `deleteProduct()` 事件: ```js deleteProduct() { const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/product/${this.tempProduct.id}` this.$http.delete(api).then(response => { console.log(response.data); }) $('#delProductModal').modal('hide') this.getProducts() } ``` ## 九、htm串接上傳檔案 API(上傳圖片) 1. 在 modal 中找到上傳圖片的 `input` 標籤加上 `@change` 事件,當此 `input` 欄位有改變時就觸發 `uploadFile()` 方法: ```html <div class="form-group"> <label for="customFile">或 上傳圖片 <i class="fas fa-spinner fa-spin"></i> </label> <input type="file" id="customFile" class="form-control" ref="files" @change="uploadFile"> </div> ``` 2. `uploadFile()` 製作: + 若我們上傳了一個檔案,我們可以在 `this.$refs` 裡面找到一些線索,而該檔案的完整位址是 `this.$refs.files.files[0]`。 + 根據 API 文件,上傳圖片的 API 傳送的檔案格式必須是 form-data,所以這邊透過 Web APIs 的 `new FormData` 建構子來創造一個空的 [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData) 物件,再用 `formData.append('欄位名稱', '值')` 的語法將值寫入。 ```txt API 文件: <form action="/api/thisismycourse2/admin/upload" enctype="multipart/form-data" method="post"> <input type="file" name="file-to-upload"> <input type="submit" value="Upload"> </form> ``` + 當發送上傳檔案的 Ajax 請求時,除了要帶上已經填入值的 [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData) 物件,還需要帶上第三個參數: ```js { headers: { 'Content-Type': 'multipart/form-data' } } ``` 藉由這個 header 來告訴後端此次傳送的檔案格式是 form-data,後端才看得懂。若沒有設定這個 header `'Content-Type'` 的預設值為 `application/json` JSON 格式。 + 完整的 `uploadFile()`: ```js uploadFile() { const uploadedFile = this.$refs.files.files[0] const formData = new FormData() const vm = this formData.append('file-to-upload', uploadedFile) // 上傳檔案的 API const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/upload` this.$http.post(api, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(response => { console.log(response.data); // 如果上傳成功就將回傳的圖片網址寫入 vm.tempProduct.imageUrl if (response.data.success) { // vm.tempProduct.imageUrl = response.data.imageUrl vm.$set(vm.tempProduct, 'imageUrl', response.data.imageUrl) } }) } ``` 小叮嚀,在將回傳的圖片網址寫入 `vm.tempProduct.imageUrl` 這個部分,會發生明明有寫入資料,但是在畫面上沒有渲染出來,此時使用 `vm.$set(target, key, value)` 語法將資料寫入可以改善這個問題。 3. 記得 modal 的 `<img>` 標籤動態綁定 `:src="tempProduct.imageUrl"` 圖片才能正確顯示。 ### Q&A Q:我們點擊 `<input type="file">` 選擇圖片以後,其實就已經把圖片傳給後端了,此時不論有沒有按下表單的確認鍵,圖片都已經在後端的資料庫裡面,因為只要觸發 `@change` 事件就會觸發 `uploadFile()` 方法,這樣後端是不是會存入很多用不到的圖片檔案? A:是的,後端確實會存入很多沒用到的圖檔。 ## 十、增加使用者體驗 - 讀取中效果製作 1. 使用 [Vue Loading Overlay](https://github.com/ankurk91/vue-loading-overlay) 套件 安裝:`npm install vue-loading-overlay` 2. 在 `main.js` import 此套件: `vue-loading.css` 要 import 在 `main.js` 或 `src/assets/all.scss` 裡面都可以 ```js import Loading from 'vue-loading-overlay' import 'vue-loading-overlay/dist/vue-loading.css' Vue.component('loading', Loading) ``` 3. 啟用,在 `Products.vue` 的 `<template>` 裡面的的最上方加上 `<loading :active.sync="isLoading"></loading>`: ```html <template> <div> <loading :active.sync="isLoading"></loading> <!-- 略... --> ``` 在 data 新增 `isLoading` 變數,當 `isLoading` 為 true 時就會有讀取中的效果: ```js data() { return { products: [], tempProduct: {}, isNew: false, isLoading: false, // 新增 } }, ``` 4. 在有 Ajax 行為的時候調整 `isLoading` 的真假值: ```js methods: { getProducts() { const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/products/all` const vm = this vm.isLoading= true // 取得商品就顯示讀取動畫 this.$http.get(api).then((response) => { vm.isLoading= false // Ajax 發送結束又取消動畫 vm.products = response.data.products }) }, } ``` ### 使用 Font Awesome 的 Animating Icon 1. 在 `index.html` 的 `<head>` 標籤中引入 CDN 2. data 資料新增 status 變數: ```js data() { return { products: [], tempProduct: {}, isNew: false, isLoading: false, status: { // 新增 fileUploading: false, }, } }, ``` 3. 從 Font Awesome 自選一個歡的 [Animating Icon](https://fontawesome.com/how-to-use/on-the-web/styling/animating-icons),這裡選的是 `<i class="fas fa-spinner fa-spin"></i>` 4. 在 `<template>` 中綁定 `v-if`: ```html <!-- true 就顯示,false 就不顯示 --> <label for="customFile">或 上傳圖片 <i class="fas fa-spinner fa-spin" v-if="status.fileUploading"></i> </label> ``` 5. 在自己想要放的地方替換 `fileUploading` 真假值,這邊是用在上傳圖片的方法中: **看註解!** ```js uploadFile() { const uploadedFile = this.$refs.files.files[0] const formData = new FormData() const vm = this formData.append('file-to-upload', uploadedFile) const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/upload` vm.status.fileUploading = true // 觸發上傳圖片方法就開始跑動畫 this.$http.post(api, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(response => { console.log(response.data); vm.status.fileUploading = false // Ajax 結束後就取消 if (response.data.success) { vm.$set(vm.tempProduct, 'imageUrl', response.data.imageUrl) } }) } ``` ## 十一、增加使用者體驗 - 錯誤的訊息回饋 目的:Event bus 直接掛載在 Vue 的原型下來直接操作這個 `AlertMessage.vue`。 > 此小節節錄並改寫自 [itsems](https://medium.com/itsems-frontend/vue-event-bus-15b76f27aeb9)。 ### Event Bus 是什麼? 在 Vue 的組件之間,常見的父子組件溝通方式為 props、emit,但如果是兄弟姊妹組件、跨組件之間的溝通,為了避免繁複的 props emit,就可以使用 `Event Bus` 來做。`Event Bus` 像是一個全域的事件,可以給各組件共用,適用沒有太複雜的專案和事件,如警示視窗。 要在專案中使用 eventBus 其實就是在 Vue 裡面再註冊一個 Vue 實例,eventBus 主要會使用到 Vue 實例中的四種方法: `$on` :註冊監聽 `$once`:只監聽一次 `$off`:取消監聽 `$emit`:送出事件 建議在頁面 created 的時候就註冊監聽,並在組件銷毀前取消監聽。 範例中我們嘗試製作一個 alert.vue 組件,作為一個全域的警示通知,不管在哪個組件中都可以把這個 alert 事件呼叫出來。 1. 在 `src/components` 下新增 `AlertMessage.vue`,內容可直接複製貼上[課程模板](https://github.com/hexschool/vue-course-api-wiki/wiki/%E8%AA%B2%E7%A8%8B%E9%83%A8%E5%88%86%E6%A8%A1%E6%9D%BF),`created()` 部分的註解記得打開。 2. 回 `Dashboard.vue` import `AlertMessage.vue`: ```js import Navbar from './Navbar' import Sidebar from './Sidebar' import AlertMessage from './AlertMessage' // 新增 export default { components: { Sidebar, Navbar, AlertMessage, // 新增 }, // ...略 } ``` 將 `<AlertMessage>` 放入 `<template>`: ```html <template> <div> <Navbar></Navbar> <AlertMessage></AlertMessage> <!-- ...略 --> ``` 到這個步驟,畫面不會有任何變化,預設是不顯示的 3. `/src` 下新增 `bus.js` 並配置: ```js import Vue from 'vue' Vue.prototype.$bus = new Vue() ``` 4. 回 `main.js` import `bus.js`: ```js import App from './App' import router from './router' import './bus' // 新增 ``` 5. 在想要的地方提供錯誤提示,這邊是在上傳圖片失敗時(錯誤格式或者圖檔太大),加入 ` this.$bus.$emit('message:push', '自定義提示文字', 'bootstrap主題樣式')` ```js uploadFile() { const uploadedFile = this.$refs.files.files[0] const formData = new FormData() const vm = this formData.append('file-to-upload', uploadedFile) const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/upload` vm.status.fileUploading = true this.$http.post(api, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(response => { console.log(response.data); vm.status.fileUploading = false if (response.data.success) { vm.$set(vm.tempProduct, 'imageUrl', response.data.imageUrl) } else { // 若上傳失敗則呼叫 event bus this.$bus.$emit('message:push', response.data.message, 'danger') } }) } ``` ## 十二、產品列表的分頁邏輯 觀察 **取得商品** 的 API 會發現發送 Ajax 成功後會回傳 pagination 資訊: ```json "pagination": { "total_pages": 1, "current_page": 1, "has_pre": false, "has_next": false, "category": null }, ``` **此案例是後端直接傳來的分頁資訊設計良好,前端不用再做很多判斷。** 1. 回 `Products.vue` 在 data 新增 pagination 變數: ```js data() { return { products: [], pagination: {}, // 新增 tempProduct: {}, isNew: false, isLoading: false, status: { fileUploading: false, }, } }, ``` 2. 在取得商品資訊 `getProducts()` 後,把回傳的 pagination 存入 `this.pagination`: ```js getProducts() { const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/products/` const vm = this vm.isLoading= true this.$http.get(api).then((response) => { console.log(response.data); vm.isLoading= false vm.products = response.data.products vm.pagination = response.data.pagination // 新增 }) }, ``` 3. 複製 bootstrap 的 [paginaion 模板](https://getbootstrap.com/docs/4.3/components/pagination/),並貼在 `<talbe>` 表格標籤後面 4. 邏輯: ```html <nav aria-label="Page navigation example"> <ul class="pagination"> <!-- 上一頁按鈕 --> <li class="page-item" :class="{ 'disabled': !pagination.has_pre }" @click.prevent="getProducts(pagination.current_page - 1)"> <a class="page-link" href="#" aria-label="Previous"> <span aria-hidden="true">&laquo;</span> </a> </li> <!-- 頁碼 --> <li class="page-item" @click.prevent="getProducts(page)" v-for="page in pagination.total_pages" :key="page" :class="{ 'active': pagination.current_page === page }"><a class="page-link" href="#">{{ page }}</a></li> <!-- 下一頁按鈕 --> <li class="page-item" :class="{ 'disabled': !pagination.has_next }" @click.prevent="getProducts(pagination.current_page + 1)"> <a class="page-link" href="#" aria-label="Next"> <span aria-hidden="true">&raquo;</span> </a> </li> </ul> </nav> ``` + 頁碼: + 後端傳來的 `pagination.total_pages` 數值得知總共幾頁,用 `v-for` 印出,`:key="page"`用頁碼綁定。 + 後端傳來的 `pagination.current_page` 數值得知目前所在頁碼,用 `:class` 加上 'active' 的 class。 + 點選時切換頁碼:將當前頁碼當作參數傳入 `getProducts()` + 上下頁按鈕: + 後端傳來的 `pagination.has_next` 和 `pagination.has_pre` 得知上下頁按鈕存不存在,再用 `:class` 加上 'disabled' 的 class。 + 點選時切換頁碼:利用 `pagination.current_page` 加減數值 1 的結果傳入 `getProducts()` 作為參數。 ### 補充 `getProducts()` 中使用的 API 需要輸入頁碼來請求該頁的產品資料,而這個頁碼是自己傳入,這邊利用 ES6 的預設參數: ```js getProducts(page = 1) { const api = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/admin/products/?page=${page}` const vm = this vm.isLoading= true this.$http.get(api).then((response) => { console.log(response.data); vm.isLoading= false vm.products = response.data.products vm.pagination = response.data.pagination }) }, ``` 此小節內容可以做成一個 pagination 元件,可參考此[範例](https://github.com/WeiweiJoanne/vuecli/blob/pagination/src/components/Pagination.vue)。 ## 十三、套用價格的 Filter 技巧 目的:在顯示價格時,增加千分號及 $ 符號。 1. `src/filters` 下新增 `currency.js` 配置: ```js export default function (num) { const n = Number(num); return `$${n.toFixed(0).replace(/./g, (c, i, a) => { const currency = (i && c !== '.' && ((a.length - i) % 3 === 0) ? `, ${c}`.replace(/\s/g, '') : c); return currency; })}`; } ``` 2. 回 `main.js` import `currency.js` 並啟用: ```js import currencyFilter from './filters/currency' Vue.filter('currency', currencyFilter) ``` 3. 在 `Products.vue` 中套用: ```html <tr v-for="item in products" :key="item.id"> <td>{{ item.category }}</td> <td>{{ item.title }}</td> <td class="text-right">{{ item.origin_price | currency}}</td> <td class="text-right">{{ item.price | currency}}</td> <!-- 略... --> ``` ## --------------- 分隔線,以下開始為前臺畫面(客戶端) 客戶端行為與管理者端行為大同小異,故前臺畫面製作直接提供模板參考。 `src\components\CustomerOrders.vue` ```html <template> <div> <loading :active.sync="isLoading"></loading> <div class="row mt-4"> <div class="col-md-3 mb-3" v-for="item in products" :key="item.id"> <div class="card border-0 shadow-sm"> <div style="height: 150px; background-size: cover; background-position: center" :style="{backgroundImage: `url(${item.imageUrl})`}"> </div> <div class="card-body"> <span class="badge badge-secondary float-right ml-2">{{ item.category }}</span> <h5 class="card-title"> <a href="#" class="text-dark">{{ item.title }}</a> </h5> <p class="card-text">{{ item.content }}</p> <div class="d-flex justify-content-between align-items-baseline"> <div class="h5" v-if="!item.price">{{ item.origin_price }} 元</div> <del class="h6" v-if="item.price">原價 {{ item.origin_price }} 元</del> <div class="h5" v-if="item.price">現在只要 {{ item.price }} 元</div> </div> </div> <div class="card-footer d-flex"> <button type="button" class="btn btn-outline-secondary btn-sm" @click="getProduct(item.id)"> <i class="fas fa-spinner fa-spin" v-if="status.loadingItem === item.id"></i> 查看更多 </button> <button type="button" class="btn btn-outline-danger btn-sm ml-auto" @click="addtoCart(item.id)"> <i class="fas fa-spinner fa-spin" v-if="status.loadingItem === item.id"></i> 加到購物車 </button> </div> </div> </div> </div> <div class="modal fade" id="productModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="exampleModalLabel">{{ product.title }}</h5> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">&times;</span> </button> </div> <div class="modal-body"> <img :src="product.imageUrl" class="img-fluid" alt=""> <blockquote class="blockquote mt-3"> <p class="mb-0">{{ product.content }}</p> <footer class="blockquote-footer text-right">{{ product.description }}</footer> </blockquote> <div class="d-flex justify-content-between align-items-baseline"> <div class="h4" v-if="!product.price">{{ product.origin_price }} 元</div> <del class="h6" v-if="product.price">原價 {{ product.origin_price }} 元</del> <div class="h4" v-if="product.price">現在只要 {{ product.price }} 元</div> </div> <select name="" class="form-control mt-3" v-model="product.num"> <option :value="num" v-for="num in 10" :key="num"> 選購 {{num}} {{product.unit}} </option> </select> </div> <div class="modal-footer"> <div class="text-muted text-nowrap mr-3"> 小計 <strong>{{ product.num * product.price }}</strong> 元 </div> <button type="button" class="btn btn-primary" @click="addtoCart(product.id, product.num)"> <i class="fas fa-spinner fa-spin" v-if="product.id === status.loadingItem"></i> 加到購物車 </button> </div> </div> </div> </div> <div class="my-5 row justify-content-center"> <div class="my-5 row justify-content-center"> <table class="table"> <thead> <th></th> <th>品名</th> <th>數量</th> <th>單價</th> </thead> <tbody> <tr v-for="item in cart.carts" :key="item.id" v-if="cart.carts"> <td class="align-middle"> <button type="button" class="btn btn-outline-danger btn-sm"> <i class="far fa-trash-alt"></i> </button> </td> <td class="align-middle"> {{ item.product.title }} <!-- <div class="text-success" v-if="item.coupon"> 已套用優惠券 </div> --> </td> <td class="align-middle">{{ item.qty }}/{{ item.product.unit }}</td> <td class="align-middle text-right">{{ item.final_total }}</td> </tr> </tbody> <tfoot> <tr> <td colspan="3" class="text-right">總計</td> <td class="text-right">{{ cart.total }}</td> </tr> <!-- <tr v-if="cart.final_total"> <td colspan="3" class="text-right text-success">折扣價</td> <td class="text-right text-success">{{ cart.final_total }}</td> </tr> --> </tfoot> </table> </div> </div> </div> </template> <script> import $ from 'jquery'; export default { data() { return { products: [], product: {}, status: { loadingItem: '', }, cart: {}, isLoading: false, }; }, methods: { getProducts() { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/products`; vm.isLoading = true; this.$http.get(url).then((response) => { vm.products = response.data.products; console.log(response); vm.isLoading = false; }); }, getProduct(id) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/product/${id}`; vm.status.loadingItem = id; this.$http.get(url).then((response) => { vm.product = response.data.product; $('#productModal').modal('show'); console.log(response); vm.status.loadingItem = ''; }); }, addtoCart(id, qty = 1) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/cart`; vm.status.loadingItem = id; const cart = { product_id: id, qty, }; this.$http.post(url, { data: cart }).then((response) => { console.log(response); vm.status.loadingItem = ''; vm.getCart(); $('#productModal').modal('hide'); }); }, getCart() { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/cart`; vm.isLoading = true; this.$http.get(url).then((response) => { // vm.products = response.data.products; vm.cart = response.data.data; console.log(response); vm.isLoading = false; }); }, }, created() { this.getProducts(); this.getCart(); }, }; </script> ``` ## 十四、Dashboard 新增模擬購物頁面 - 新增卡片式產品列表 + `<loading :active.sync="isLoading"></loading>` 等待中動畫標籤記得要加。 + ```txt :style="{ backgroundImage: `url(${item.imageUrl})` }" ``` 動態綁定 style,利用字串模板和 `background-image` CSS 屬性替產品加上圖片。 ## 十五、取得單一產品 + 取得單筆資料 API:`/api/:api_path/product/:id` + 當點擊 `查看更多` 按鈕會再另行彈出一個 modal 顯示產品詳細內容 1. data 新增 product 變數存取當前點擊的產品資訊: ```js data() { return { products: [], product: {}, // 新增 isLoading: false, }; }, ``` 2. 在 `查看更多` 按鈕上新增事件,點擊時就將當前 item.id 傳入方法: ```html <button type="button" class="btn btn-outline-secondary btn-sm" @click="getProduct(item.id)"> <i class="fas fa-spinner fa-spin" v-if="status.loadingItem === item.id"></i> 查看更多 </button> ``` 3. 串接 API,事件處發時讓 modal 出現: ```js getProduct(id) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/product/${id}`; this.$http.get(url).then((response) => { vm.product = response.data.product; $('#productModal').modal('show'); console.log(response); }); }, ``` 4. 增加使用者體驗,點擊 `查看更多` 按鈕時按鈕旁邊出現轉圈圈動畫: + data 新增 status 變數: ```js data() { return { products: [], product: {}, status: { loadingItem: '', // 新增 }, isLoading: false, }; }, ``` + `vm.status.loadingItem` 的值寫入傳入的 id ```js getProduct(id) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/product/${id}`; vm.status.loadingItem = id; // 新增 this.$http.get(url).then((response) => { vm.product = response.data.product; $('#productModal').modal('show'); console.log(response); vm.status.loadingItem = ''; // 新增 }); }, ``` + FontAwesome 的 `<i>` 標籤上新增 `v-if` 條件: ```html <button type="button" class="btn btn-outline-secondary btn-sm" @click="getProduct(item.id)"> <i class="fas fa-spinner fa-spin" v-if="status.loadingItem === item.id"></i> 查看更多 </button> ``` ## 十六、選購產品及加入購物車 ### 加入購物車: + 加入購物車 API:`/api/:api_path/cart` + 參數:`{ "data": { "product_id":"-L9tH8jxVb2Ka_DYPwng","qty":1 } }` + `addtoCart()` 加入購物車方法: ```js // 根據 API 文件必須傳入產品 id 及數量,數量預設為 1 addtoCart(id, qty = 1) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/cart`; vm.status.loadingItem = id; const cart = { product_id: id, qty, }; this.$http.post(url, { data: cart }).then((response) => { console.log(response); vm.status.loadingItem = ''; vm.getCart(); $('#productModal').modal('hide'); }); }, ``` + 在 HTML 按鈕上加入事件: ```html <button type="button" class="btn btn-outline-danger btn-sm ml-auto" @click="addtoCart(item.id)"> <i class="fas fa-spinner fa-spin" v-if="status.loadingItem === item.id"></i> 加到購物車 </button> ``` modal 裡面的加入購物車按鈕,此選單可選擇商品數量: ```html <div class="modal-body"> <img :src="product.imageUrl" class="img-fluid" alt=""> <blockquote class="blockquote mt-3"> <p class="mb-0">{{ product.content }}</p> <footer class="blockquote-footer text-right">{{ product.description }}</footer> </blockquote> <div class="d-flex justify-content-between align-items-baseline"> <div class="h4" v-if="!product.price">{{ product.origin_price }} 元</div> <del class="h6" v-if="product.price">原價 {{ product.origin_price }} 元</del> <div class="h4" v-if="product.price">現在只要 {{ product.price }} 元</div> </div> <!-- value 記得用動態綁定方式 --> <select name="" class="form-control mt-3" v-model="product.num"> <option :value="num" v-for="num in 10" :key="num"> 選購 {{num}} {{product.unit}} </option> </select> </div> <div class="modal-footer"> <div class="text-muted text-nowrap mr-3"> 小計 <strong>{{ product.num * product.price }}</strong> 元 </div> <button type="button" class="btn btn-primary" @click="addtoCart(product.id, product.num)"> <i class="fas fa-spinner fa-spin" v-if="product.id === status.loadingItem"></i> 加到購物車 </button> </div> ``` + **使用 `v-model` 去判定使用者選擇的數量** + `<option>` 標籤 value 記得用動態綁定方式 + `@click="addtoCart(product.id, product.num)"` 參數來自於打開 modal 就將該產品資訊存入的 this.product ### 取得購物車: + 取得購物車 API:`/api/:api_path/cart` + `getCart()`: ```js getCart() { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/cart`; vm.isLoading = true; this.$http.get(url).then((response) => { // vm.products = response.data.products; vm.cart = response.data.data; console.log(response); vm.isLoading = false; }); }, ``` 記得加入完購物車須重新取得購物車資料,在 `created()` 的時候也要將購物車資料取回。 ## 十七、刪除購物車品項及新增優惠碼 ### 刪除某一筆購物車資料 ```txt [API]: /api/:api_path/cart/:id [方法]: delete ``` ```html <td class="align-middle"> <button type="button" class="btn btn-outline-danger btn-sm" @click="removeCartItem(item.id)"> <i class="far fa-trash-alt"></i> </button> </td> ``` ```js removeCartItem(id) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/cart/${id}`; vm.isLoading = true; this.$http.delete(url).then((response) => { vm.getCart() console.log(response); vm.isLoading = false; }); } ``` ### 套用優惠券 ```txt [API]: /api/:api_path/coupon [方法]: post [說明]: Coupon 套用時,全部統一套用 ``` 當最終價格不等於沒有套用優惠券價格時顯示: ```html <tr v-if="cart.final_total !== cart.total"> <td colspan="3" class="text-right text-success">折扣價</td> <td class="text-right text-success">{{ cart.final_total }}</td> </tr> ``` 目前 commit 進度 `CustomerOrders.vue`: ```html <template> <div> <loading :active.sync="isLoading"></loading> <div class="row mt-4"> <div class="col-md-4 mb-3" v-for="item in products" :key="item.id"> <div class="card border-0 shadow-sm"> <div style="height: 150px; background-size: cover; background-position: center" :style="{backgroundImage: `url(${item.imageUrl})`}"> </div> <div class="card-body"> <span class="badge badge-secondary float-right ml-2">{{ item.category }}</span> <h5 class="card-title"> <a href="#" class="text-dark">{{ item.title }}</a> </h5> <p class="card-text">{{ item.content }}</p> <div class="d-flex justify-content-between align-items-baseline"> <div class="h5" v-if="!item.price">{{ item.origin_price }} 元</div> <del class="h6" v-if="item.price">原價 {{ item.origin_price }} 元</del> <div class="h5" v-if="item.price">現在只要 {{ item.price }} 元</div> </div> </div> <div class="card-footer d-flex"> <button type="button" class="btn btn-outline-secondary btn-sm" @click="getProduct(item.id)"> <i class="fas fa-spinner fa-spin" v-if="status.loadingItem === item.id"></i> 查看更多 </button> <button type="button" class="btn btn-outline-danger btn-sm ml-auto" @click="addtoCart(item.id)"> <i class="fas fa-spinner fa-spin" v-if="status.loadingItem === item.id"></i> 加到購物車 </button> </div> </div> </div> </div> <div class="modal fade" id="productModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="exampleModalLabel">{{ product.title }}</h5> <button type="button" class="close" data-dismiss="modal" aria-label="Close"> <span aria-hidden="true">&times;</span> </button> </div> <div class="modal-body"> <img :src="product.imageUrl" class="img-fluid" alt=""> <blockquote class="blockquote mt-3"> <p class="mb-0">{{ product.content }}</p> <footer class="blockquote-footer text-right">{{ product.description }}</footer> </blockquote> <div class="d-flex justify-content-between align-items-baseline"> <div class="h4" v-if="!product.price">{{ product.origin_price }} 元</div> <del class="h6" v-if="product.price">原價 {{ product.origin_price }} 元</del> <div class="h4" v-if="product.price">現在只要 {{ product.price }} 元</div> </div> <select name="" class="form-control mt-3" v-model="product.num"> <option :value="num" v-for="num in 10" :key="num"> 選購 {{num}} {{product.unit}} </option> </select> </div> <div class="modal-footer"> <div class="text-muted text-nowrap mr-3"> 小計 <strong>{{ product.num * product.price }}</strong> 元 </div> <button type="button" class="btn btn-primary" @click="addtoCart(product.id, product.num)"> <i class="fas fa-spinner fa-spin" v-if="product.id === status.loadingItem"></i> 加到購物車 </button> </div> </div> </div> </div> <div class="my-5 row justify-content-center"> <div class="my-5 row justify-content-center"> <table class="table"> <thead> <th></th> <th>品名</th> <th>數量</th> <th>單價</th> </thead> <tbody> <tr v-for="item in cart.carts" :key="item.id" v-if="cart.carts"> <td class="align-middle"> <button type="button" class="btn btn-outline-danger btn-sm" @click="removeCartItem(item.id)"> <i class="far fa-trash-alt"></i> </button> </td> <td class="align-middle"> {{ item.product.title }} <!-- <div class="text-success" v-if="item.coupon"> 已套用優惠券 </div> --> </td> <td class="align-middle">{{ item.qty }}/{{ item.product.unit }}</td> <td class="align-middle text-right">{{ item.final_total }}</td> </tr> </tbody> <tfoot> <tr> <td colspan="3" class="text-right">總計</td> <td class="text-right">{{ cart.total }}</td> </tr> <tr v-if="cart.final_total"> <td colspan="3" class="text-right text-success">折扣價</td> <td class="text-right text-success">{{ cart.final_total }}</td> </tr> </tfoot> </table> <div class="input-group mb-3 input-group-sm"> <input type="text" class="form-control" v-model="coupon_code" placeholder="請輸入優惠碼"> <div class="input-group-append"> <button class="btn btn-outline-secondary" type="button" @click="addCouponCode"> 套用優惠碼 </button> </div> </div> </div> </div> </div> </template> <script> import $ from 'jquery'; export default { data() { return { products: [], product: {}, status: { loadingItem: '', }, cart: {}, isLoading: false, coupon_code: '' }; }, methods: { getProducts() { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/products`; vm.isLoading = true; this.$http.get(url).then((response) => { vm.products = response.data.products; console.log(response); vm.isLoading = false; }); }, getProduct(id) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/product/${id}`; vm.status.loadingItem = id; this.$http.get(url).then((response) => { vm.product = response.data.product; $('#productModal').modal('show'); console.log(response); vm.status.loadingItem = ''; }); }, addtoCart(id, qty = 1) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/cart`; vm.status.loadingItem = id; const cart = { product_id: id, qty, }; this.$http.post(url, { data: cart }).then((response) => { console.log(response); vm.status.loadingItem = ''; vm.getCart(); $('#productModal').modal('hide'); }); }, getCart() { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/cart`; vm.isLoading = true; this.$http.get(url).then((response) => { // vm.products = response.data.products; vm.cart = response.data.data; console.log(response); vm.isLoading = false; }); }, removeCartItem(id) { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/cart/${id}`; vm.isLoading = true; this.$http.delete(url).then((response) => { vm.getCart() console.log(response); vm.isLoading = false; }); }, addCouponCode() { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/coupon`; const coupon = { coode: vm.coupon_code } vm.isLoading = true; this.$http.post(url, { data: coupon }).then((response) => { vm.getCart() console.log(response); vm.isLoading = false; }); } }, created() { this.getProducts(); this.getCart(); }, }; </script> ``` ## 十八、建立訂單及表單驗證技巧 ### 建立訂單 API: ```txt [API]: /api/:api_path/order [方法]: post [說明]: 建立訂單後會把所選的購物車資訊刪除, message 欄位為必填, user 物件為必要 [參數]: { "data": { "user": { "name": "test", "email": "test@gmail.com", "tel": "0912346768", "address": "kaohsiung" }, "message": "這是留言" } } ``` 1. data 新增準備要送出的 form 資料(根據 API 文件要求) ```js data() { return { products: [], product: {}, status: { loadingItem: '', }, cart: {}, isLoading: false, coupon_code: '', // 新增 內容由 input v-model 帶入 form: { user: { name: '', email:'', tel:'', address: '', }, message: '', }, }; }, ``` 2. 新增 `createOrder()` 方法並添加 submit 事件在表單上 ```js createOrder() { const vm = this; const url = `${process.env.APIPATH}/api/${process.env.CUSTOMPATH}/order`; const order = vm.form vm.isLoading = true; this.$http.post(url, { data: order }).then((response) => { console.log('訂單已建立', response); vm.isLoading = false; }); } ``` ### 表單驗證 使用 [VeeValidate 套件](https://logaretm.github.io/vee-validate/overview.html#getting-started) 1. 安裝:`npm install vee-validate --save` 2. 回 `main.js` import 套件 ```js import { ValidationObserver, ValidationProvider, extend, localize, configure } from 'vee-validate'; import TW from 'vee-validate/dist/locale/zh_TW.json' import * as rules from 'vee-validate/dist/rules'; Object.keys(rules).forEach((rule) => { extend(rule, rules[rule]); }); localize('zh_TW', TW); Vue.component('ValidationObserver', ValidationObserver) Vue.component('ValidationProvider', ValidationProvider) configure({ classes: { // 針對 bootstrap class 的設定 valid: 'is-valid', invalid: 'is-invalid' } }); ``` + ValidationProvider 是 `<input>` 驗證元件 + ValidationObserver 則是 `<form>` 驗證元件 #### 驗證 `<input>` 使用 `<validation-provider>` 元件標籤包覆 `<input>` 段落: ```html <validation-provider rules="required|email" v-slot="{ errors, classes }"> <div class="form-group"> <!-- 輸入框 --> <label for="email">Email</label> <input id="email" type="email" name="email" v-model="form.user.email" class="form-control" :class="classes"> <!-- 錯誤訊息 --> <span class="invalid-feedback">{{ errors[0] }}</span> </div> </validation-provider> ``` 1. 建立 validation-provider 元件: 2. rules 帶上驗證的規則,規則列表可 [參考](https://logaretm.github.io/vee-validate/guide/rules.html#rules)。注意:規則之間不需要帶上空白鍵。 3. v-slot 帶上預計回傳的回饋內容,常用的可參考下方範例,完整列表可 [參考](https://logaretm.github.io/vee-validate/api/validation-provider.html#scoped-slot-props) 4. 建立 input 欄位內容 5. 將回饋帶至驗證(v-slot 的內容) ##### 補充 `v-slot` 傳入的 classes 用來在 `<input>` 上用 `:class` 綁定 bootstrap 樣式。 classes 由 `main.js` 引入時寫入: ```js configure({ classes: { valid: 'is-valid', invalid: 'is-invalid' } }) ``` #### 驗證 `<form>` 使用 `<validation-observer>` 元件標籤包覆 `<form>` 段落: ```html <validation-observer v-slot="{ invalid }"> <form class="col-md-6" @submit.prevent="createOrder"> <validation-provider rules="required|email" v-slot="{ errors, classes }"> <div class="form-group"> <label for="email">Email</label> <input id="email" type="email" name="email" v-model="form.user.email" class="form-control" :class="classes"> <!-- 錯誤訊息 --> <span class="invalid-feedback">{{ errors[0] }}</span> </div> <!-- 輸入框 --> </validation-provider> <div class="text-right"> <button :disabled="invalid" class="btn btn-danger">送出訂單</button> </div> </form> </validation-observer> ``` 如果驗證未通過則 disabled 該按鈕。 --- ## 頁碼邏輯與製作 ### 當你用 Ajax 撈回一個陣列資料時,你可以: ```js pageCouter(categoryName) { // 1. 先複製一份資料起來 const filterData = [...this.products].reverse() // 自定義選項:依照價格高低篩選 if (this.filter.price === '由高到低') { filterData.sort((a, b) => b.price - a.price) } if (this.filter.price === '由低到高') { filterData.sort((a, b) => a.price - b.price) } // 把篩選過後的資料抓出來:可能是依照產品分類或者顏色大小等等...... let totalProducts = filterData.filter(item => item.category == categoryName) // 算出篩選過後的資料長度(多少筆資料) let totalProductsCounts = filterData.filter(item => item.category == categoryName).length // 如果類別是 '全部' 就把剛剛篩選的資料重新賦值過 if (categoryName === '全部') { totalProducts = filterData totalProductsCounts = filterData.length } // 一頁要放幾項 item const itemsPerPage = this.pagination.itemsPerPage // 利用資料長度 / 一頁要放幾項 item 再無條件進位可算出總共會有多少頁碼 this.pagination.totalPages = Math.ceil(totalProductsCounts / itemsPerPage) // 算出要從第幾個 Index 開始取出陣列中的值 const offset = (this.pagination.currentPage - 1) * itemsPerPage // slice(begin, end) 可以取出陣列中的一部份出來(沒有包含 end) // end 減去 begin 的值等於要從陣列中擷取幾筆資料 return totalProducts.slice(offset, offset + itemsPerPage) } ``` 資料結構: ```js data() { return { pagination: { currentPage: 1, itemsPerPage: 10, totalPages: '', }, }; }, ``` ### `Pagination.vue` 頁碼元件製作 1. 先將外部資料傳給 Pagination 元件: ```html <!-- In BrowseProducts.vue --> <Pagination :pagination="pagination"></Pagination> ``` 2. Pagination 元件接收資料 ```html <script> // :pages="{ 頁碼資訊 }" export default { name: 'Pagination', props: ['pagination'], }; </script> ``` 3. 製作 Pagination 元件 `<template>` ```html <template> <nav aria-label="Page navigation example"> <ul class="pagination justify-content-center"> <li class="page-item" :class="{ 'disabled': pagination.currentPage === 1 }"> <a class="page-link" href="#" aria-label="Previous"> <span aria-hidden="true">&laquo;</span> <span class="sr-only">Previous</span> </a> </li> <li :class="{ 'active': pagination.currentPage === page }" class="page-item" v-for="page in pagination.totalPages" :key="page"> <a class="page-link" href="#">{{ page }}</a> </li> <li class="page-item" :class="{ 'disabled': pagination.currentPage === pagination.totalPages }"> <a class="page-link" href="#" aria-label="Next"> <span aria-hidden="true">&raquo;</span> <span class="sr-only">Next</span> </a> </li> </ul> </nav> </template> ``` 利用 props 接收的資料對頁碼 HTML 設計一下: + 上下頁按鈕各別在首頁及最末頁是沒有作用的,用 `:class` 加上 disabled class。 + 頁碼利用 `v-for` 製作,且被選定時要加上 active class。 #### 換頁功能 1. 利用 emit 將元件資料傳給外層資料: ```html <template> <nav aria-label="Page navigation example"> <ul class="pagination justify-content-center"> <li class="page-item" :class="{ 'disabled': pagination.currentPage === 1 }"> <a class="page-link" href="#" aria-label="Previous" @click.prevent="updatePage(pagination.currentPage - 1)"><!-- 點擊時將該頁頁碼 emit 到外層 --> <span aria-hidden="true">&laquo;</span> <span class="sr-only">Previous</span> </a> </li> <li :class="{ 'active': pagination.currentPage === page }" class="page-item" v-for="page in pagination.totalPages" :key="page"><!-- 點擊時將該頁頁碼 emit 到外層 --> <a class="page-link" href="#" @click.prevent="updatePage(page)">{{ page }}</a> </li> <li class="page-item" :class="{ 'disabled': pagination.currentPage === pagination.totalPages }"> <a class="page-link" href="#" aria-label="Next" @click.prevent="updatePage(pagination.currentPage + 1)"><!-- 點擊時將該頁頁碼 emit 到外層 --> <span aria-hidden="true">&raquo;</span> <span class="sr-only">Next</span> </a> </li> </ul> </nav> </template> ``` ```html <script> // @emitPages="更新頁面事件" export default { name: 'Pagination', props: ['pagination'], methods: { updatePage(page) { this.$emit('emitPages', page); }, }, }; </script> ``` 2. 外層接收 emit 資料 ```html <!-- In BrowseProducts.vue --> <Pagination :pagination="pagination" @emitPages="updatePage"></Pagination> <!-- 注意這邊的 updatePage() 跟 Pagination 中的是不一樣的,只是名稱取一樣而已。 --> ``` ```js methods: { updatePage(emittedPage) { // 將 emit 到外層的頁碼替換本來的 currentPage this.pagination.currentPage = emittedPage // 滾動到最上面 $('html,body').animate({ scrollTop: 0 }, 'slow') } } ``` 由於渲染於畫面的資料是經由 computed 運算(`this.pagination.currentPage` 有寫在裡面),當發生 `updatePage(emittedPage)` 資料跟畫面會同步更新。 ## `decodeURIComponent()` 小叮嚀 這邊從首頁點選熱門產品分類連結進入產品列表頁面是透過 query string 方式再用 `this.$route.query.category` 去取出 query string 的值再將 `this.filter.category` 替換掉方能正確顯示分類,而若傳入 query string 中的值帶有空白鍵或其他符號的話,經過編譯可能會與原本不相同,這是利用 `decodeURIComponent()` 可讓經過編譯的 query string 還原成原字串。 ## Eslint、eslint-plugin-vue 設定 我是選擇 Standard,可參考此篇[詳細解說](https://pjchender.blogspot.com/2019/07/vue-vue-style-guide-eslint-plugin-vue.html)。 ```json // VSCode settings.json { "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "eslint.alwaysShowStatus": true, "eslint.validate": [ "javascript", "javascriptreact", "typescript", "typescriptreact", "vue", ], "vetur.validation.template": false, // 有安裝 vetur 套件的話 } ```