wordpress_blog

This is a dynamic to static website.

Vue3 複習09

最終挑戰

最終作業課程介紹

課程目標

透過課程專屬 API 完成獨立作品

電商網站操作流程

  • 管理員
  • 用戶

開發的架構說明

  • 前端
    • 前台
    • 後台
  • 後端

學習面向

  • 後台 – 登入授權、編輯商品、檢視訂單、建立優惠券…
    學習開發流程、後台邏輯建構
  • 前台 – 檢視商品、選購並加入購物車、提交訂單、模擬付款…
    • 完整作品經營
    • 美感、細節培養
    • 解決問題的能力

API 說明

[API]: https://站點/api/:api_path/admin/product

  • 申請帳號 – 登入授權也是使用此帳號
  • 申請專屬 API 連結 – 資料內容也會獨立
  • 開始練習

API 文件

[API]: https://站點/api/:api_path/admin/product

[方法]: POST

// [參數]:
{
  "data": {
    "title": "[賣]動物園造型衣服3",
    "category": "衣服2",
    ...
  }
}

課程 API 文件及相關資源

  1. 課程需要先註冊屬於個人的 API 路徑,註冊方法在下一小節會介紹,註冊網址與 API 站點連結
  2. API 文件
  3. 課程中後期,不會所有步驟都一一說明,所以課程中有提供每個階段的 commit,讓大家可以看到每個章節老師修改了哪些部分:所有課程進度 Commit (對應課程章節)
  4. 課程中也會提供許多 HTML 片段模板,減少重複繁瑣的行為,如提到會提供模板的部分,連結
  5. 雖然課程中 ESLint 選擇為 Airbnb 格式:
    • 體驗簡單一點的開發規則可選擇 Standard
    • 對 ES6 及錯誤排除有一定掌握者可選擇 Airbnb

關於 ESLint 搭配 VSCode 的自動排版可參考(注意,並非所有錯誤都可自動排除):連結

使用時 ESLint 時:

  • 可多利用文字編輯器的提示來除錯(除錯也是開發者必學的技能之一)
  • 盡可能避免關閉 ESLint 的提示

申請課程 API

流程說明:

  1. 申請一個專屬的課程練習帳號
  2. 登入帳號,並申請一個 API 路徑
  3. 測試 API 是否可以運作,並且開始實作

複習 Vue Cli 建立環境

  1. 使用 Vue Cli 建立環境 vue create vue3_dashboard_record
  2. Please pick a preset: Manually select features
  3. Check the features needed for your project: Choose Vue version, Babel, Router, CSS Pre-processors, Linter
  4. Choose a version of Vue.js that you want to start the project with 3.x (Preview)
  5. Use history mode for router? (Requires proper server setup for index fallback in production) No
  6. Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with node-sass)
  7. Pick a linter / formatter config: Airbnb
  8. Pick additional lint features: Lint on save
  9. Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
  10. Save this as a preset for future projects? (y/N) N

API 路徑加到環境變數

  1. 在根目錄新增 .env 檔案
    API Server 路徑: VUE_APP_API
    API 個人路徑: VUE_APP_PATH
  2. 開啟 Home.vue 檔案調整程式碼內容
    使用生命週期讀出環境變數
  3. 重新執行 npm run serve
  4. 開啟 Console 查看環境變數是否有顯示
// .env
VUE_APP_API=http://localhost:3000/
VUE_APP_PATH=geehsu-api
// views/Home.vue
<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue';

export default {
  name: 'Home',
  components: {
    HelloWorld,
  },
  created() {
    console.log(process.env.VUE_APP_API, process.env.VUE_APP_PATH);
  },
};
</script>
// 除錯 Console 警告
// vue.config.js
module.exports = {
  chainWebpack: (config) => {
    config.plugin('define').tap((definitions) => {
      Object.assign(definitions[0], {
        __VUE_OPTIONS_API__: 'true',
        __VUE_PROD_DEVTOOLS__: 'false',
        __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
      })
      return definitions
    })
  }
}

常見 API 問題解決方式

路徑與方法是一對的

API 是由路徑 + 方法組成的,文件中皆會標示所有 API 可用的方法如 GET、POST、DELETE 等等,必須使用完全對應的方法才能運作喔。

從錯誤訊息中尋找問題

API 發送的過程中錯一個字就會無法運行,盡可能從錯誤的回饋中尋找問題,檢查:

  • 路徑是否拼對
  • GET、POST 等方法是否正確
  • 程式碼運作是否如預期

遇到錯誤無法解決

發問流程:
可以提供完整程式碼讓老師、助教測試
可以將原始碼上傳至 GitHub 或雲端硬碟,並將說明及連結提供在問答區
上傳時特別注意: 不需要夾帶 node_modules、dist 資料夾
這會導致信件無法開啟,且下載後依然要刪除重新安裝。

另外請盡可能提供完整訊息,由於許多問題並非片段就能判斷
所以盡可能提供越完整越好

  • 錯誤的問題描述 (哪一個 API、做了什麼事、預期有怎樣的進展、錯誤的問題點)
  • 錯誤的 API 連結為何
  • 完整錯誤的訊息圖片 (Chrome Console)
  • 出現錯誤的程式碼

匯入 Bootstrap 並調整版型

  1. 安裝 Bootstrap 套件 – npm install bootstrap
  2. 引用 Bootstrap 到專案裡面
    Bootstrap 文件 > Customize > Sass > Importing
    複製 @import “../node_modules/bootstrap/scss/bootstrap”;
  3. 開啟 App.vue 檔案貼上程式碼後改寫
    @import “~bootstrap/scss/bootstrap”;
  4. 重新運行 npm run serve
  5. 在 Bootstrap 文件 > 元件 > 按鈕
    複製按鈕程式碼貼到 App.vue 檔案測試是否有正確載入 Bootstrap 套件
  6. 客製化 Bootstrap 樣式
    在 assets 資料夾新增 all.scss 檔案
    在 assets 資料夾新增 helpers 資料夾
    在 assets/helpers 新增 _variables.scss 檔案 (_在scss不會被編譯出來)
  7. 找到 node_modules/bootstrap/scss/_variables.scss 打開
    複製 _variables.scss 所有程式碼直接貼到 assets/helpers/_variables.scss 檔案
  8. 客製化變數
    在 Bootstrap 文件 > Customize > Sass > Importing
    複製文件需要的程式碼貼到 assets/all.scss 並改寫
    匯入所有的 Bootstrap
  9. 在 App.vue 檔案調整成自定義 Sass 匯入路徑
  10. 在 assets/helpers/_variables.scss 檔案調整變數
// App.vue
<template>
  <div id="nav">
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
  </div>
  <button type="button" class="btn btn-primary">Primary</button>
  <button type="button" class="btn btn-secondary">Secondary</button>
  <button type="button" class="btn btn-success">Success</button>
  <button type="button" class="btn btn-danger">Danger</button>
  <button type="button" class="btn btn-warning">Warning</button>
  <button type="button" class="btn btn-info">Info</button>
  <button type="button" class="btn btn-light">Light</button>
  <button type="button" class="btn btn-dark">Dark</button>

  <button type="button" class="btn btn-link">Link</button>
  <router-view/>
</template>

<style lang="scss">
@import "./assets/all";
</style>
// assets/helpers/_variables.scss
// scss-docs-start theme-colors-map
$theme-colors: (
  "primary":    black , // $primary,
  "secondary":  $secondary,
  "success":    $success,
  "info":       $info,
  "warning":    $warning,
  "danger":     $danger,
  "light":      $light,
  "dark":       $dark
) !default;
// scss-docs-end theme-colors-map
// assets/all.scss
// 1. Include functions first (so you can manipulate colors, SVGs, calc, etc)
@import "~bootstrap/scss/functions";

// 2. Include any default variable overrides here

// 3. Include remainder of required Bootstrap stylesheets (including any separate color mode stylesheets)
// 客製化 variables 路徑
@import "./helpers/variables";
@import "~bootstrap/scss/variables-dark";

// 4. Include any default map overrides here

// 5. Include remainder of required parts
@import "~bootstrap/scss/maps";
@import "~bootstrap/scss/mixins";
@import "~bootstrap/scss/root";

// 匯入所有的 Bootstrap
@import "~bootstrap/scss/bootstrap";

製作登入頁面

注意事項:.env 檔中的站點需要調整才能夠對應到文件 範例如下:

VUE_APP_API=https://vue3-course-api.hexschool.io/

登入及驗證vue-axios課程部分模板

  1. 查看登入及驗證 API 文件
    [API]、[方法]、[參數]、[成功回應]、[失敗回應]
  2. 安裝 vue-axios 套件
    npm install –save axios vue-axios
  3. 匯入 vue-axios 套件
    Vue2, Vue3 載入方式有所不同
    複製程式碼貼到 main.js 檔案
  4. 調整 main.js 檔案結構
  5. 重新運行 npm run serve
  6. 新增 views/Login.vue 檔案
    製作簡單頁面確保現在頁面已經建立成功
    建立檔案再調整路由
  7. 查看 login 畫面有無正確運作
    http://localhost:8080/#/login
  8. 清除 App.vue 檔案多餘的程式碼片段
  9. 在 Login.vue 加入登入的版型
    使用課程部分模板調整使用
  10. 撰寫 Login.vue <script> JS 的部分
    查看登入及驗證文件登入參數的物件格式、撰寫在 data 資料
    使用 v-model 雙向綁定表單資料
  11. 測試是否能正常運作
    輸入表單資料送出、查看 Vue.js devtools
  12. 串接 API
    在 Login.vue 撰寫 methods 方法
    把事件加到 <form> 標籤,使用 @submit 事件
    可加上 prevent 避免觸發 html 預設事件
  13. 串接 API 必須把路徑組起來
    環境變數站點位置+登入的實際 API
    ${process.env.VUE_APP_API}/admin/signin
  14. 送出 api
    使用 this.$http.post 方法
  15. 試著登入測試是否有回傳資料
// src/main.js
import { createApp } from 'vue';
import axios from 'axios';
import VueAxios from 'vue-axios';
import App from './App.vue';
import router from './router';

const app = createApp(App);
app.use(VueAxios, axios);
app.use(router);
app.mount('#app');
// views/Login.vue
<template>
  <div class="container mt-5">
    <form class="row justify-content-center"
    @submit.prevent="signIn">
      <div class="col-md-6">
        <h1 class="h3 mb-3 font-weight-normal">請先登入</h1>
        <div class="mb-2">
          <label for="inputEmail" class="sr-only">Email address</label>
          <input
            type="email"
            id="inputEmail"
            class="form-control"
            placeholder="Email address"
            required
            autofocus
            v-model="user.username"
          />
        </div>
        <div class="mb-2">
          <label for="inputPassword" class="sr-only">Password</label>
          <input
            type="password"
            id="inputPassword"
            class="form-control"
            placeholder="Password"
            required
            v-model="user.password"
          />
        </div>
        <div class="text-end mt-4">
          <button class="btn btn-lg btn-primary btn-block" type="submit">登入</button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      user: {
        username: '',
        password: '',
      },
    };
  },
  methods: {
    signIn() {
      console.log('login');
      // 環境變數站點位置 + 登入的實際 API
      const api = `${process.env.VUE_APP_API}admin/signin`;
      console.log(api);
      // api 路徑, 夾帶的資料
      this.$http.post(api, this.user)
        .then((res) => {
          console.log(res);
        });
    },
  },
};
</script>
// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    component: () => import('../views/Login.vue'),
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
// App.vue
<template>
  <div id="nav">
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
  </div>
  <router-view/>
</template>

<style lang="scss">
@import "./assets/all";
</style>
// .env
VUE_APP_API=https://vue3-course-api.hexschool.io/
VUE_APP_PATH=geehsu-api

登入與 Cookie

  1. 查看瀏覽器 Cookie 儲存位置
  2. 查看 MDN 文件 – Cookie 文件連結
  3. 撰寫 document.cookie 程式碼
  4. 登入後查看瀏覽器 Cookie 是否有正確儲存
// views/Login.vue
<template>
  <div class="container mt-5">
    <form class="row justify-content-center"
    @submit.prevent="signIn">
      <div class="col-md-6">
        <h1 class="h3 mb-3 font-weight-normal">請先登入</h1>
        <div class="mb-2">
          <label for="inputEmail" class="sr-only">Email address</label>
          <input
            type="email"
            id="inputEmail"
            class="form-control"
            placeholder="Email address"
            required
            autofocus
            v-model="user.username"
          />
        </div>
        <div class="mb-2">
          <label for="inputPassword" class="sr-only">Password</label>
          <input
            type="password"
            id="inputPassword"
            class="form-control"
            placeholder="Password"
            required
            v-model="user.password"
          />
        </div>
        <div class="text-end mt-4">
          <button class="btn btn-lg btn-primary btn-block" type="submit">登入</button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      user: {
        username: '',
        password: '',
      },
    };
  },
  methods: {
    signIn() {
      console.log('login');
      // 環境變數站點位置 + 登入的實際 API
      const api = `${process.env.VUE_APP_API}admin/signin`;
      console.log(api);
      // api 路徑, 夾帶的資料
      this.$http.post(api, this.user)
        .then((res) => {
          // Cookie 存取
          // 前面: 儲存 Cookie 內容; 後面: 到期日
          // 儲存 Cookie 內容: 自定義名稱 = token 值
          // 到期日轉成 token 可以儲存的編碼
          const { token, expired } = res.data;
          // console.log(token, expired);
          document.cookie = `hexToken=${token}; expires=${new Date(expired)}`;
          console.log(res);
        });
    },
  },
};
</script>

Cookie 存取的語法

MDN 文件,將 Cookie 存入、取出: 連結

// MDN 文件,將 Cookie 存入、取出 - 範例程式碼
const token = response.data.token;
const expired = response.data.expired;
console.log(token, expired);
document.cookie = `hexToken=${token};expires=${new Date(expired)};`;

Axios 文件,設定預設 Headers: 連結

// Axios 文件,設定預設 Headers - 範例程式碼
const token = document.cookie.replace(/(?:(?:^|.*;\s*)hexToken\s*=\s*([^;]*).*$)|^.*$/, '$1');
this.$http.defaults.headers.common.Authorization = `${token}`;

確認是否維持登入狀態

  1. 查看登入及驗證文件
    檢查用戶是否仍持續登入
    驗證方法 – 將 Token 加入 Headers
  2. Token
    把 Token 從 Cookie 取出
    把 Token 加到 Headers
  3. 查看 MDN 文件 – Cookie 文件連結
    複製 myCookie 程式碼並做改寫
  4. 查看 axios 文件 – Global axios defaults
  5. 實作把 Cookie 取出來、以及把 Token 發送出去
  6. 新增 views/Dashboard.vue 檔案
    在 router/index.js 檔案把 Dashboard 加到路由表
    http://localhost:8080/#/dashboard
    查看是否有正確運作
  7. 在 Dashboard.vue 檔案撰寫程式碼
    專注在 JS 撰寫
    取出 token
    把token 夾帶到 headers 裡面
    複製 Global axios defaults 程式碼
  8. 試著觸發剛剛的 API
    複製 Login.vue signIn() 裡面的程式碼
    把多餘的程式碼清掉
    檢查用戶是否仍然持續登入,把 API 路徑改成 api/user/check
  9. 查看 console 回傳的 data 是否是成功
  10. 清除 Cookie 查看是否仍正確登入
  11. 在 Login.vue 檔案補上登入判斷,登入成功後會轉址到 Dashboard 頁面
  12. 在 Dashboard.vue 檔案補上登入判斷,登入失敗後會轉址到 Login 頁面
// views/Dashboard.vue
<template>
  Dashboard
</template>

<script>
export default {
  created() {
    // 取出 token
    // test2 改寫成 hexToken
    // Airbnb 規範把錯誤的 \ 去除
    const token = document.cookie.replace(/(?:(?:^|.*;\s*)hexToken\s*=\s*([^;]*).*$)|^.*$/, '$1');
    // console.log(token);
    // token 夾帶到 headers 裡面
    // ["Authorization"] 改寫成 .Authorization
    this.$http.defaults.headers.common.Authorization = token;
    // 檢查用戶是否仍然持續登入
    const api = `${process.env.VUE_APP_API}api/user/check`;
    this.$http.post(api)
      .then((res) => {
        // console.log(res);
        // 登入判斷 - 登入失敗
        if (!res.data.success) {
          // 登入失敗後會轉址到 Login 頁面
          this.$router.push('/login');
        }
      });
  },
};
</script>
// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    component: () => import('../views/Login.vue'),
  },
  {
    path: '/dashboard',
    component: () => import('../views/Dashboard.vue'),
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
// views/Login.vue
<template>
  <div class="container mt-5">
    <form class="row justify-content-center"
    @submit.prevent="signIn">
      <div class="col-md-6">
        <h1 class="h3 mb-3 font-weight-normal">請先登入</h1>
        <div class="mb-2">
          <label for="inputEmail" class="sr-only">Email address</label>
          <input
            type="email"
            id="inputEmail"
            class="form-control"
            placeholder="Email address"
            required
            autofocus
            v-model="user.username"
          />
        </div>
        <div class="mb-2">
          <label for="inputPassword" class="sr-only">Password</label>
          <input
            type="password"
            id="inputPassword"
            class="form-control"
            placeholder="Password"
            required
            v-model="user.password"
          />
        </div>
        <div class="text-end mt-4">
          <button class="btn btn-lg btn-primary btn-block" type="submit">登入</button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      user: {
        username: '',
        password: '',
      },
    };
  },
  methods: {
    signIn() {
      // console.log('login');
      // 環境變數站點位置 + 登入的實際 API
      const api = `${process.env.VUE_APP_API}admin/signin`;
      // console.log(api);
      // api 路徑, 夾帶的資料
      this.$http.post(api, this.user)
        .then((res) => {
          // 登入判斷 - 成功狀態
          if (res.data.success) {
            // Cookie 存取
            // 前面: 儲存 Cookie 內容; 後面: 到期日
            // 儲存 Cookie 內容: 自定義名稱 = token 值
            // 到期日轉成 token 可以儲存的編碼
            const { token, expired } = res.data;
            // console.log(token, expired);
            document.cookie = `hexToken=${token}; expires=${new Date(expired)}`;
            // console.log(res);
            // 登入成功會轉址到 Dashboard 頁面
            this.$router.push('/dashboard');
          }
        });
    },
  },
};
</script>

調整巢狀路由並且加入 Navbar

  1. 新增 Products.vue 檔案 – 產品頁面
  2. 在 Dashboard.vue 檔案新增 Navbar
    在 Bootstrap 文件搜尋 Navbar 尋找相對簡單的程式碼複製使用
  3. 在 App.vue 檔案把預設的連結清除
    複製 <router-view> 貼到 Dashboard.vue
  4. 打開 router/index.js 路由表
    在 dashboard 路由新增 products 子路由
    http://localhost:8080/#/dashboard/products
  5. 在 Login.vue 檔案調整程式碼
    登入成功後的轉址改為 /dashboard/products
  6. 在 Dashboard.vue 檔案把 Navbar 程式碼拆分到元件
    在 src/components 新增 Navbar.vue 檔案
    把 Dashboard.vue 檔案 Navbar 程式碼剪下貼到 Navbar.vue 檔案
  7. 在 Dashboard.vue 匯入 Navbar.vue
    import Navbar from ‘../components/Navbar.vue’;
    使用 components 區域註冊 Navbar
    再把 <Navbar> 標籤加到畫面
  8. 在 Navbar.vue 新增登出的功能
    查看登入及驗證文件
    從 Login.vue 檔案查看 signIn 方法
    撰寫 Navbar.vue logout 方法
// views/Products.vue
<template>
  產品列表
</template>
// views/Dashboard.vue
<template>
  <Navbar></Navbar>
  <router-view/>
</template>

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

export default {
  components: {
    Navbar,
  },
  created() {
    // 取出 token
    // test2 改寫成 hexToken
    // Airbnb 規範把錯誤的 \ 去除
    const token = document.cookie.replace(/(?:(?:^|.*;\s*)hexToken\s*=\s*([^;]*).*$)|^.*$/, '$1');
    // console.log(token);
    // token 夾帶到 headers 裡面
    // ["Authorization"] 改寫成 .Authorization
    this.$http.defaults.headers.common.Authorization = token;
    // 檢查用戶是否仍然持續登入
    const api = `${process.env.VUE_APP_API}api/user/check`;
    this.$http.post(api)
      .then((res) => {
        // console.log(res);
        // 登入判斷 - 登入失敗
        if (!res.data.success) {
          // 登入失敗後會轉址到 Login 頁面
          this.$router.push('/login');
        }
      });
  },
};
</script>
// App.vue
<template>
  <router-view/>
</template>

<style lang="scss">
@import "./assets/all";
</style>
// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    component: () => import('../views/Login.vue'),
  },
  {
    path: '/dashboard',
    component: () => import('../views/Dashboard.vue'),
    // 巢狀路由 - 產品列表
    children: [
      {
        path: 'products',
        component: () => import('../views/Products.vue'),
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
// Login.vue
<template>
  <div class="container mt-5">
    <form class="row justify-content-center"
    @submit.prevent="signIn">
      <div class="col-md-6">
        <h1 class="h3 mb-3 font-weight-normal">請先登入</h1>
        <div class="mb-2">
          <label for="inputEmail" class="sr-only">Email address</label>
          <input
            type="email"
            id="inputEmail"
            class="form-control"
            placeholder="Email address"
            required
            autofocus
            v-model="user.username"
          />
        </div>
        <div class="mb-2">
          <label for="inputPassword" class="sr-only">Password</label>
          <input
            type="password"
            id="inputPassword"
            class="form-control"
            placeholder="Password"
            required
            v-model="user.password"
          />
        </div>
        <div class="text-end mt-4">
          <button class="btn btn-lg btn-primary btn-block" type="submit">登入</button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      user: {
        username: '',
        password: '',
      },
    };
  },
  methods: {
    signIn() {
      // console.log('login');
      // 環境變數站點位置 + 登入的實際 API
      const api = `${process.env.VUE_APP_API}admin/signin`;
      // console.log(api);
      // api 路徑, 夾帶的資料
      this.$http.post(api, this.user)
        .then((res) => {
          // 登入判斷 - 成功狀態
          if (res.data.success) {
            // Cookie 存取
            // 前面: 儲存 Cookie 內容; 後面: 到期日
            // 儲存 Cookie 內容: 自定義名稱 = token 值
            // 到期日轉成 token 可以儲存的編碼
            const { token, expired } = res.data;
            // console.log(token, expired);
            document.cookie = `hexToken=${token}; expires=${new Date(expired)}`;
            // console.log(res);
            // 登入成功會轉址到 Dashboard 頁面
            // 改為 /dashboard/products
            this.$router.push('/dashboard/products');
          }
        });
    },
  },
};
</script>
// src/components/Navbar.vue
<template>
  <nav class="navbar navbar-expand-lg bg-body-tertiary">
    <div class="container-fluid">
      <a class="navbar-brand" href="#">Navbar w/ text</a>
      <button class="navbar-toggler" type="button"
      data-bs-toggle="collapse" data-bs-target="#navbarText"
      aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarText">
        <ul class="navbar-nav me-auto mb-2 mb-lg-0">
          <li class="nav-item">
            <a class="nav-link active" aria-current="page" href="#">Home</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="#">Features</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="#" @click.prevent="logout">登出</a>
          </li>
        </ul>
        <span class="navbar-text">
          Navbar text with an inline element
        </span>
      </div>
    </div>
  </nav>
</template>

<script>
export default {
  methods: {
    logout() {
      const api = `${process.env.VUE_APP_API}logout`;
      this.$http.post(api, this.user)
        .then((res) => {
          if (res.data.success) {
            // console.log(res);
            this.$router.push('/login');
          }
        });
    },
  },
};
</script>

導航守衛

防止使用者觀看到後台介面。

參考: Day17:如何防止使用者未登錄就要訪問頁面?

// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import axios from 'axios';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/Login.vue'),
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('../views/Dashboard.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: 'products',
        name: 'Products',
        component: () => import('../views/Products.vue'),
        meta: { requiresAuth: true },
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

router.beforeEach((to, from, next) => {
  console.log('導航守衛啟動');
  if (to.meta.requiresAuth) {
    console.log('need auth');
    const token = document.cookie.replace(/(?:(?:^|.*;\s*)hexToken\s*=\s*([^;]*).*$)|^.*$/, '$1');
    axios.defaults.headers.common.Authorization = token;
    const api = `${process.env.VUE_APP_API}api/user/check`;
    axios.post(api).then((res) => {
      console.log(res.data);
      console.log(res.data.success);
      if (res.data.success) {
        next();
      } else {
        next({
          path: '/login',
        });
      }
    });
  } else next();
});

export default router;

參考: Vue Router Guard

// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/Login.vue'),
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('../views/Dashboard.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: 'products',
        name: 'Products',
        component: () => import('../views/Products.vue'),
        meta: { requiresAuth: true },
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

document.isAuthenticated = false;

router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !document.isAuthenticated) next({ name: 'Login' });
  else next();
});

export default router;
// views/Login.vue
<template>
  <div class="container mt-5">
    <form class="row justify-content-center"
    @submit.prevent="signIn">
      <div class="col-md-6">
        <h1 class="h3 mb-3 font-weight-normal">請先登入</h1>
        <div class="mb-2">
          <label for="inputEmail" class="sr-only">Email address</label>
          <input
            type="email"
            id="inputEmail"
            class="form-control"
            placeholder="Email address"
            required
            autofocus
            v-model="user.username"
          />
        </div>
        <div class="mb-2">
          <label for="inputPassword" class="sr-only">Password</label>
          <input
            type="password"
            id="inputPassword"
            class="form-control"
            placeholder="Password"
            required
            v-model="user.password"
          />
        </div>
        <div class="text-end mt-4">
          <button class="btn btn-lg btn-primary btn-block" type="submit">登入</button>
        </div>
      </div>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      user: {
        username: '',
        password: '',
      },
    };
  },
  methods: {
    signIn() {
      // console.log('login');
      // 環境變數站點位置 + 登入的實際 API
      const api = `${process.env.VUE_APP_API}admin/signin`;
      // console.log(api);
      // api 路徑, 夾帶的資料
      this.$http.post(api, this.user)
        .then((res) => {
          // 登入判斷 - 成功狀態
          if (res.data.success) {
            // Cookie 存取
            // 前面: 儲存 Cookie 內容; 後面: 到期日
            // 儲存 Cookie 內容: 自定義名稱 = token 值
            // 到期日轉成 token 可以儲存的編碼
            const { token, expired } = res.data;
            // console.log(token, expired);
            document.cookie = `hexToken=${token}; expires=${new Date(expired)}`;
            // console.log(res);
            // 登入成功會轉址到 Dashboard 頁面
            document.isAuthenticated = true;
            this.$router.push('/dashboard/products');
          }
        });
    },
  },
};
</script>

建立 NotFound 頁面,然後轉址到首頁。

// views/NotFound.vue
<template>
  Not Found
</template>

<script>
export default {
  created() {
    setTimeout(() => this.$router.push({
      path: '/',
    }), 5000);
  },
};
</script>
// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/Login.vue'),
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('../views/Dashboard.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: 'products',
        name: 'Products',
        component: () => import('../views/Products.vue'),
        meta: { requiresAuth: true },
      },
    ],
  },
  // 404 頁面
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('../views/NotFound.vue'),
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

document.isAuthenticated = false;

router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !document.isAuthenticated) next({ name: 'Login' });
  else next();
});

export default router;

加入產品列表

  1. 主要撰寫 Products.vue 檔案
    稍微調整 Dashboard.vue 檔案
  2. 調整 Dashboard.vue 檔案程式碼
    <router-view> 標籤外層加上 .container-fluid
  3. 在 Products.vue 檔案
    用列表的形式完成,複製課程部分模板程式碼貼上
  4. 在 Products.vue 撰寫 JS 部分
    定義 data() 回傳資料,products 資料是陣列、分頁資訊是物件
  5. 在 Products.vue 撰寫取得遠端資料的方法
    定義 methods 物件,getProducts() 方法
    getProducts() 取得產品列表是多數的產品資訊
  6. 查看 API 文件 – 管理控制台 [需驗證]
    找到取得商品列表,透過 API 路徑取得遠端資料
    參考 Login.vue 檔案 signIn() 方法調整成取得商品列表的 API 結構
    [API]: /api/:api_path/admin/products?page=:page
    尚未製作分頁可以先把分頁後面的 API 移除
    把 API 和 PATH 改寫成環境變數的路徑
    ${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products
    API 方法是使用 get
  7. 使用生命週期 created() 觸發 getProducts() 方法
    查看 Console 是否有正確取得遠端資料
    可以儲存產品資訊和分頁資訊,調整 getProducts() 方法程式碼
  8. 使用 Vue 開發者工具檢視
  9. 把產品資訊渲染到畫面上
// views/Products.vue
<template>
  <table class="table mt-4">
    <thead>
      <tr>
        <th width="120">分類</th>
        <th>產品名稱</th>
        <th width="120">原價</th>
        <th width="120">售價</th>
        <th width="100">是否啟用</th>
        <th width="200">編輯</th>
      </tr>
    </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 class="text-success" v-if="item.is_enabled">啟用</span>
          <span class="text-muted" v-else>未啟用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm">編輯</button>
            <button class="btn btn-outline-danger btn-sm">刪除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script>
export default {
  data() {
    return {
      // 產品資訊
      products: [],
      // 分頁資訊
      pagination: {},
    };
  },
  methods: {
    // 取得產品列表是多數的產品資訊
    getProducts() {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products`;
      this.$http.get(api)
        .then((res) => {
          if (res.data.success) {
            console.log(res.data);
            this.products = res.data.products;
            this.pagination = res.data.pagination;
          }
        });
    },
  },
  created() {
    this.getProducts();
  },
};
</script>
// views/Dashboard.vue
<template>
  <Navbar></Navbar>
  <div class="container-fluid">
    <router-view/>
  </div>
</template>

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

export default {
  components: {
    Navbar,
  },
  created() {
    // 取出 token
    // test2 改寫成 hexToken
    // Airbnb 規範把錯誤的 \ 去除
    const token = document.cookie.replace(/(?:(?:^|.*;\s*)hexToken\s*=\s*([^;]*).*$)|^.*$/, '$1');
    // console.log(token);
    // token 夾帶到 headers 裡面
    // ["Authorization"] 改寫成 .Authorization
    this.$http.defaults.headers.common.Authorization = token;
    // 檢查用戶是否仍然持續登入
    const api = `${process.env.VUE_APP_API}api/user/check`;
    this.$http.post(api)
      .then((res) => {
        // console.log(res);
        // 登入判斷 - 登入失敗
        if (!res.data.success) {
          // 登入失敗後會轉址到 Login 頁面
          this.$router.push('/login');
        }
      });
  },
};
</script>

增加 Bootstrap Modal

  1. 製作新增產品的彈跳視窗
    使用事件的方式呼叫元件
  2. 查看 Bootstrap 文件
    元件 > 互動視窗 Modal > 完整範例 Live Demo,是透過 HTML 方式呼叫 Modal
    因為使用 Vue.js,所以盡可能使用 JS 方式呼叫 Modal
    元件 > 互動視窗 Modal > 用法 > 傳遞選項
    使用 new bootstrap modal 方法把 modal 實體化
  3. 在 components 資料夾新增 ProductModal.vue 檔案
  4. 在 Bootstrap 複製完整範例 Live Demo Modal 部分的程式碼貼到 ProductModeal.vue 檔案
  5. 在 ProductModal.vue 檔案加入 JS
    著重在 JS 的部分
    在元件新增方法讓外部元件呼叫
  6. 調整 ProductModal.vue 檔案 HTML 的部分
    加上 ref 屬性,透過 ref 方式直接存取 DOM 元素
  7. 撰寫 ProductModal.vue 檔案 JS 的部分
  8. 參考 Bootstrap 文件 元件 > 互動視窗 Modal > 用法 > 透過 JavaScript
    複製程式碼貼到 mounted 裡面
  9. 在調用之前必須把 Bootstrap 的 Modal 方法載出來
    在 node_modules/bootstrap/js/dist/modal.js 檔案,把 modal.js 檔案載進來
    會使用 import 方法載入 Modal
    調整 mounted 裡面程式碼,改成 new Modal
  10. 透過 refs方式把 DOM 元素指向外層的 ref modal
    繼續調整 mounted 裡面程式碼
    this.$refs.modal
  11. 前面的變數再指回 data 裡面定義的變數
    改成 this.modal
  12. 在 mounted 加上 this.modal.show();
  13. 在 Products.vue 檔案
    直接到 JS 部分直接使用 import 方式把 ProductModal 方法載進來
    定義 components 進行 ProductModal 區域註冊
    把 Productmodal 加到畫面上面
  14. 把剩餘的一些方法補上
    showModal 方法、hideModal 方法
  15. 在 Products.vue 檔案
    找到 <ProductModal> 標籤定義名稱 ref=”productModal”
    使用 ref 方式直接呼叫 Modal 方法
  16. 在 Products.vue 檔案
    在上方加入一些程式碼
    使用 @click=”$refs.productModal 方式指向所定義的元件,直接呼叫裡面的 showModal 方法
// components/ProductModal.vue
<template>
  <!-- Modal -->
  <div class="modal fade" id="exampleModal" tabindex="-1"
  aria-labelledby="exampleModalLabel" aria-hidden="true"
  ref="modal">
    <div class="modal-dialog">
      <div class="modal-content">
        <div class="modal-header">
          <h1 class="modal-title fs-5" id="exampleModalLabel">Modal title</h1>
          <button type="button" class="btn-close" data-bs-dismiss="modal"
          aria-label="Close"></button>
        </div>
        <div class="modal-body">
          ...
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
          <button type="button" class="btn btn-primary">Save changes</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
// 載入 Modal
import Modal from 'bootstrap/js/dist/modal';

export default {
  data() {
    return {
      // 實體內容回傳出來
      // modal 變數定義為物件
      modal: {},
    };
  },
  // 方法
  methods: {
    showModal() {
      this.modal.show();
    },
    hideModal() {
      this.modal.hide();
    },
  },
  // 實體必須在元件載入之後才能正確運作
  // mounted 生命週期
  mounted() {
    this.modal = new Modal(this.$refs.modal);
  },
};
</script>
// views/Products.vue
<template>
  <div class="text-end">
    <button class="btn btn-primary" type="button"
    @click="$refs.productModal.showModal()">
      增加一個產品
    </button>
  </div>
  <table class="table mt-4">
    <thead>
      <tr>
        <th width="120">分類</th>
        <th>產品名稱</th>
        <th width="120">原價</th>
        <th width="120">售價</th>
        <th width="100">是否啟用</th>
        <th width="200">編輯</th>
      </tr>
    </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 class="text-success" v-if="item.is_enabled">啟用</span>
          <span class="text-muted" v-else>未啟用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm">編輯</button>
            <button class="btn btn-outline-danger btn-sm">刪除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
  <ProductModal ref="productModal"></ProductModal>
</template>

<script>
// 載入 ProductModal
import ProductModal from '../components/ProductModal.vue';

export default {
  data() {
    return {
      // 產品資訊
      products: [],
      // 分頁資訊
      pagination: {},
    };
  },
  // 定義 components 進行區域註冊
  components: {
    ProductModal,
  },
  methods: {
    // 取得產品列表是多數的產品資訊
    getProducts() {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products`;
      this.$http.get(api)
        .then((res) => {
          if (res.data.success) {
            console.log(res.data);
            this.products = res.data.products;
            this.pagination = res.data.pagination;
          }
        });
    },
  },
  created() {
    this.getProducts();
  },
};
</script>

透過彈出視窗新增品項

  1. 新增產品在互動邏輯是相對複雜很多
  2. 查看管理控制台 [需驗證] API 文件 – 商品建立
  3. 新增產品是如何運作
    預期點擊右上方新增產品會跳出一個彈出視窗,輸入內容後按下確認把資料新增
  4. 這個章節最複雜的是有一個列表的元件,彈出視窗會有另外一個元件,這兩個元件之間的溝通是這個章節最複雜的地方
  5. 透過繪圖的形式了解元件之間是怎麼溝通
  6. 在 ProductModal.vue、Products.vue 檔案
    在 ProductModal.vue 檔案建構表單所需要的 HTML 以及 v-model 雙向綁定
  7. 提醒: 上傳圖片的行為
  8. 在 ProductModal.vue 檔案 data() 裡面新增 tempProduct 物件進行外層的資料傳送的接收
  9. 在外層 Products.vue 檔案調整程式碼
    新增 updateProduct() 方法的事件
    調整獨立 openModal() 事件
  10. openModal() 方法加到增加一個產品按鈕
  11. tempProduct 資料傳送到內層
    在內層 ProductModal.vue 建立 props 使用物件格式建立,名稱為 product
    在外層 Products.vue data() 新增 tempProduct
  12. 內層所接收的 props
    傳進來時使用 product 進行接收,product 是一個物件,預期傳進來的型別是物件 object,預設的情況下如果外層沒有正確的傳遞給予一個預設值 default() 直接回傳一個空的物件
  13. props 原則是前內後外
    在 Products.vue 檔案 <ProductModal> 標籤撰寫程式碼
    :product=”tempProduct”
  14. 運作流程,外層的 tempProduct 透過 props 傳送進來,內層會使用 product 進行接收
  15. 監聽 product 內容有沒有更動,使用 watch 監聽,watch 是一個物件,監聽外層傳進來的 props
    watch 的目的把傳進來的資料寫到 tempProduct 裡面
    因為單向數據流不可以直接修改外層的資料
    this.tempProduct = this.product;
  16. 打開開發人員工具 > Vue 開發者工具
    可以找到 <ProductModal>
    按下增加一個產品,輸入表單內容就可以看到 tempProduct 有增加一些內容
  17. 使用 emit 事件把 tempProduct 資料傳送到遠端
    在內層 ProductModal.vue 檔案 <button> 藉由按鈕觸發 emit 事件 @click=”$emit(‘update-product’)”
    觸發 emit 事件的同時把 tempProduct 向外傳遞
  18. 在外層 Products.vue 預期會使用 updateProduct 進行接收,<ProductModal> 標籤使用 @update-product=”updateProduct”
    使用前內後外的概念,前面是內層的元件、後面是外層所接收的函式
  19. 運作流程會使用 $emit 觸發外層的事件,觸發事件的名稱 updateProduct,接下來觸發 updateProduct() 函式,觸發的同時會把 tempProduct 資料內容透過參數的方式傳到 item 裡面
  20. 測試 tempProduct 參數有沒有正確傳過來
    測試新增品項功能是否能正常運作
  21. 必須把開啟的 modal 關閉、再重新取得列表的資料
// components/ProductModal.vue
<template>
  <!-- Modal -->
  <div class="modal fade" id="exampleModal" tabindex="-1"
  aria-labelledby="exampleModalLabel" aria-hidden="true"
  ref="modal">
    <!-- 請同學自行新增 v-model -->
    <div class="modal-dialog modal-xl" role="document">
      <div class="modal-content border-0">
        <div class="modal-header bg-dark text-white">
          <h5 class="modal-title" id="exampleModalLabel">
            <span>新增產品</span>
          </h5>
          <button type="button" class="btn-close"
                  data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <div class="row">
            <div class="col-sm-4">
              <div class="mb-3">
                <label for="image" class="form-label">輸入圖片網址</label>
                <input type="text" class="form-control" id="image"
                        placeholder="請輸入圖片連結"
                        v-model="tempProduct.imageUrl">
              </div>
              <div class="mb-3">
                <label for="customFile" class="form-label">或 上傳圖片
                  <i class="fas fa-spinner fa-spin"></i>
                </label>
                <input type="file" id="customFile" class="form-control"
                ref="fileInput"
                @change="uploadFile">
              </div>
              <img class="img-fluid" :src="tempProduct.imageUrl" alt="">
              <!-- 延伸技巧,多圖 -->
              <div class="mt-5" v-if="tempProduct.images">
                <div class="mb-3 input-group" v-for="(image, key) in tempProduct.images" :key="key">
                  <input type="url" class="form-control form-control"
                          placeholder="請輸入連結"
                          v-model="tempProduct.images[key]">
                  <button type="button" class="btn btn-outline-danger"
                  @click="tempProduct.images.splice(key, 1)">
                    移除
                  </button>
                </div>
                <div v-if="tempProduct.images[tempProduct.images.length - 1]
                || !tempProduct.images.length">
                  <button class="btn btn-outline-primary btn-sm d-block w-100"
                  @click="tempProduct.images.push('')">
                    新增圖片
                  </button>
                </div>
              </div>
            </div>
            <div class="col-sm-8">
              <div class="mb-3">
                <label for="title" class="form-label">標題</label>
                <input type="text" class="form-control" id="title"
                        placeholder="請輸入標題"
                        v-model="tempProduct.title">
              </div>

              <div class="row gx-2">
                <div class="mb-3 col-md-6">
                  <label for="category" class="form-label">分類</label>
                  <input type="text" class="form-control" id="category"
                          placeholder="請輸入分類"
                          v-model="tempProduct.category">
                </div>
                <div class="mb-3 col-md-6">
                  <label for="price" class="form-label">單位</label>
                  <input type="text" class="form-control" id="unit"
                          placeholder="請輸入單位"
                          v-model="tempProduct.unit">
                </div>
              </div>

              <div class="row gx-2">
                <div class="mb-3 col-md-6">
                  <label for="origin_price" class="form-label">原價</label>
                  <input type="number" class="form-control" id="origin_price"
                          placeholder="請輸入原價"
                          v-model.number="tempProduct.origin_price">
                </div>
                <div class="mb-3 col-md-6">
                  <label for="price" class="form-label">售價</label>
                  <input type="number" class="form-control" id="price"
                          placeholder="請輸入售價"
                          v-model.number="tempProduct.price">
                </div>
              </div>
              <hr>

              <div class="mb-3">
                <label for="description" class="form-label">產品描述</label>
                <textarea type="text" class="form-control" id="description"
                          placeholder="請輸入產品描述"
                          v-model="tempProduct.description"></textarea>
              </div>
              <div class="mb-3">
                <label for="content" class="form-label">說明內容</label>
                <textarea type="text" class="form-control" id="content"
                          placeholder="請輸入產品說明內容"
                          v-model="tempProduct.content"></textarea>
              </div>
              <div class="mb-3">
                <div class="form-check">
                  <input class="form-check-input" type="checkbox"
                          :true-value="1"
                          :false-value="0"
                          id="is_enabled"
                          v-model="tempProduct.is_enabled">
                  <label class="form-check-label" for="is_enabled">
                    是否啟用
                  </label>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-outline-secondary"
                  data-bs-dismiss="modal">取消
          </button>
          <button type="button" class="btn btn-primary"
          @click="$emit('update-product', tempProduct)">確認</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
// 載入 Modal
import Modal from 'bootstrap/js/dist/modal';

export default {
  // Props 向內層元件傳遞資料狀態
  props: {
    product: {
      type: Object,
      default() { return {}; },
    },
  },
  // 監聽
  watch: {
    product() {
      this.tempProduct = this.product;
    },
  },
  data() {
    return {
      // 實體內容回傳出來
      // modal 變數定義為物件
      modal: {},
      tempProduct: {},
    };
  },
  // 方法
  methods: {
    showModal() {
      this.modal.show();
    },
    hideModal() {
      this.modal.hide();
    },
  },
  // 實體必須在元件載入之後才能正確運作
  // mounted 生命週期
  mounted() {
    this.modal = new Modal(this.$refs.modal);
  },
};
</script>
// views/Products.vue
<template>
  <div class="text-end">
    <button class="btn btn-primary" type="button"
    @click="openModal">
      增加一個產品
    </button>
  </div>
  <table class="table mt-4">
    <thead>
      <tr>
        <th width="120">分類</th>
        <th>產品名稱</th>
        <th width="120">原價</th>
        <th width="120">售價</th>
        <th width="100">是否啟用</th>
        <th width="200">編輯</th>
      </tr>
    </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 class="text-success" v-if="item.is_enabled">啟用</span>
          <span class="text-muted" v-else>未啟用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm">編輯</button>
            <button class="btn btn-outline-danger btn-sm">刪除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
  <ProductModal ref="productModal"
  :product="tempProduct"
  @update-product="updateProduct"></ProductModal>
</template>

<script>
// 載入 ProductModal
import ProductModal from '../components/ProductModal.vue';

export default {
  data() {
    return {
      // 產品資訊
      products: [],
      // 分頁資訊
      pagination: {},
      tempProduct: {},
    };
  },
  // 定義 components 進行區域註冊
  components: {
    ProductModal,
  },
  methods: {
    // 取得產品列表是多數的產品資訊
    getProducts() {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products`;
      this.$http.get(api)
        .then((res) => {
          if (res.data.success) {
            console.log(res.data);
            this.products = res.data.products;
            this.pagination = res.data.pagination;
          }
        });
    },
    openModal() {
      this.tempProduct = {};
      const productComponent = this.$refs.productModal;
      productComponent.showModal();
    },
    updateProduct(item) {
      // 測試 tempProduct 參數有沒有正確傳過來
      // console.log(item);
      this.tempProduct = item;
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product`;
      const productComponent = this.$refs.productModal;
      this.$http.post(api, { data: this.tempProduct })
        .then((response) => {
          console.log(response);
          // modal 關閉
          productComponent.hideModal();
          // 重新取得列表資料
          this.getProducts();
        });
    },
  },
  created() {
    this.getProducts();
  },
};
</script>

產品資料更新

  1. 在 Products.vue 檔案的 tempProduct不是只為了新增,還為了編輯做使用
  2. 在範例點擊其中一個品項的編輯,會把這一個產品的品項新增到 tempProduct,送出的不是空物件,而是所點擊的這個產品的品項,因此這個 tempProduct 主要的用途是為了編輯做使用
  3. 查看管理控制台 [需驗證] API 文件 > 修改產品
    查看 [API]、[方法] 與商品建立的差異
    調整程式碼撰寫修改產品的功能
  4. 主要調整產品列表的部分
    在 Products.vue 檔案
    會透過屬性來判斷目前是否是新增的狀態,在 data() 新增一個 isNew 的狀態,目前是 false
  5. 在 openModal 新增兩個參數,一個是不是新的、另一個是編輯的話,把編輯的品項加進來
    使用 console 查看
    在 HTML 的 openModal 加入參數 true
    @click=”openModal(true)
    在品項的編輯按鈕加上 @click=”openModal(false, item),false、以及當前的品項
    當前的品項是把 v-for 裡面的 item 帶到編輯裡面
  6. 在 openModal(isNew, item) 加上流程判斷、調整程式碼
    測試編輯功能是否能正確運作
  7. 調整更新的部分
    查看管理控制台 [需驗證] API 文件 – 修改產品
    [API]、[方法]
    使用 this.isNew 流程判斷目前是新增還是修改
    調整 updateProduct(item) 方法程式碼
    測試編輯更新功能是否能正確運作
// views/Products.vue
<template>
  <div class="text-end">
    <button class="btn btn-primary" type="button"
    @click="openModal(true)">
      增加一個產品
    </button>
  </div>
  <table class="table mt-4">
    <thead>
      <tr>
        <th width="120">分類</th>
        <th>產品名稱</th>
        <th width="120">原價</th>
        <th width="120">售價</th>
        <th width="100">是否啟用</th>
        <th width="200">編輯</th>
      </tr>
    </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 class="text-success" v-if="item.is_enabled">啟用</span>
          <span class="text-muted" v-else>未啟用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm"
            @click="openModal(false, item)">編輯</button>
            <button class="btn btn-outline-danger btn-sm">刪除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
  <ProductModal ref="productModal"
  :product="tempProduct"
  @update-product="updateProduct"></ProductModal>
</template>

<script>
// 載入 ProductModal
import ProductModal from '../components/ProductModal.vue';

export default {
  data() {
    return {
      // 產品資訊
      products: [],
      // 分頁資訊
      pagination: {},
      tempProduct: {},
      // 目前狀態
      isNew: false,
    };
  },
  // 定義 components 進行區域註冊
  components: {
    ProductModal,
  },
  methods: {
    // 取得產品列表是多數的產品資訊
    getProducts() {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products`;
      this.$http.get(api)
        .then((res) => {
          if (res.data.success) {
            console.log(res.data);
            this.products = res.data.products;
            this.pagination = res.data.pagination;
          }
        });
    },
    openModal(isNew, item) {
      // console.log(isNew, item);
      // 流程判斷
      if (isNew) {
        this.tempProduct = {};
      } else {
        // 使用展開的形式取出資料
        this.tempProduct = { ...item };
      }
      this.isNew = isNew;
      const productComponent = this.$refs.productModal;
      productComponent.showModal();
    },
    updateProduct(item) {
      // 測試 tempProduct 參數有沒有正確傳過來
      // console.log(item);
      this.tempProduct = item;
      // 新增
      let api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product`;
      let httpMethod = 'post';
      // 編輯
      if (!this.isNes) {
        api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${item.id}`;
        httpMethod = 'put';
      }
      const productComponent = this.$refs.productModal;
      this.$http[httpMethod](api, { data: this.tempProduct })
        .then((response) => {
          console.log(response);
          // modal 關閉
          productComponent.hideModal();
          // 重新取得列表資料
          this.getProducts();
        });
    },
  },
  created() {
    this.getProducts();
  },
};
</script>

透過 API 上傳圖片

  1. 介紹上傳圖片的功能
  2. 查看管理控制台 [需驗證] API 文件 > 上傳圖片
    上傳表單是使用傳統的 <form> 標籤上傳的時候會用到
    action 是 API 路徑、enctype 是使用 form-data 格式、method 使用的方法是 post
    input 欄位有 name 屬性是 file-to-upload,是 API 上傳檔案需要對應的欄位
    表單傳送 action
  3. 在 ProductModal.vue 檔案
    觀看上傳圖片部分的程式碼,提及<input> @chagne=”uploadFile”、uploadFile() 方法
  4. 著重在如何把檔案取出,並轉成 form-data 格式
    首要條件是取得 input 的檔案內容,在這 <input> 先定義 ref=”fileInput”,便於取得這個 DOM 元素,並且把檔案取出來使用
  5. 在 ProductModal.vue 檔案 JS 部分著重在 uploadFile 函式
    把 input 裡面的內容取出
    使用 console.dir(uploadFile) 查看,上傳任意圖片測試,查找 files 屬性,是一個陣列,要取得的是第0個檔案,再次上傳任意圖片測試
  6. 取出的檔案轉成 form-data 格式
    宣告 formData 變數使用 new FormData() JS 方法
    formData.append,append 是要增加一個欄位到表單裡面,欄位的名稱是 API 文件 name=”file-to-upload”,欄位內容是取出來的檔案
  7. 透過 API 形式發送到遠端
    測試 console.log(response.data);
// components/ProductModal.vue
<template>
  <!-- Modal -->
  <div class="modal fade" id="exampleModal" tabindex="-1"
  aria-labelledby="exampleModalLabel" aria-hidden="true"
  ref="modal">
    <!-- 請同學自行新增 v-model -->
    <div class="modal-dialog modal-xl" role="document">
      <div class="modal-content border-0">
        <div class="modal-header bg-dark text-white">
          <h5 class="modal-title" id="exampleModalLabel">
            <span>新增產品</span>
          </h5>
          <button type="button" class="btn-close"
                  data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <div class="row">
            <div class="col-sm-4">
              <div class="mb-3">
                <label for="image" class="form-label">輸入圖片網址</label>
                <input type="text" class="form-control" id="image"
                        placeholder="請輸入圖片連結"
                        v-model="tempProduct.imageUrl">
              </div>
              <div class="mb-3">
                <label for="customFile" class="form-label">或 上傳圖片
                  <i class="fas fa-spinner fa-spin"></i>
                </label>
                <input type="file" id="customFile" class="form-control"
                ref="fileInput"
                @change="uploadFile">
              </div>
              <img class="img-fluid" :src="tempProduct.imageUrl" alt="">
              <!-- 延伸技巧,多圖 -->
              <div class="mt-5" v-if="tempProduct.images">
                <div class="mb-3 input-group" v-for="(image, key) in tempProduct.images" :key="key">
                  <input type="url" class="form-control form-control"
                          placeholder="請輸入連結"
                          v-model="tempProduct.images[key]">
                  <button type="button" class="btn btn-outline-danger"
                  @click="tempProduct.images.splice(key, 1)">
                    移除
                  </button>
                </div>
                <div v-if="tempProduct.images[tempProduct.images.length - 1]
                || !tempProduct.images.length">
                  <button class="btn btn-outline-primary btn-sm d-block w-100"
                  @click="tempProduct.images.push('')">
                    新增圖片
                  </button>
                </div>
              </div>
            </div>
            <div class="col-sm-8">
              <div class="mb-3">
                <label for="title" class="form-label">標題</label>
                <input type="text" class="form-control" id="title"
                        placeholder="請輸入標題"
                        v-model="tempProduct.title">
              </div>

              <div class="row gx-2">
                <div class="mb-3 col-md-6">
                  <label for="category" class="form-label">分類</label>
                  <input type="text" class="form-control" id="category"
                          placeholder="請輸入分類"
                          v-model="tempProduct.category">
                </div>
                <div class="mb-3 col-md-6">
                  <label for="price" class="form-label">單位</label>
                  <input type="text" class="form-control" id="unit"
                          placeholder="請輸入單位"
                          v-model="tempProduct.unit">
                </div>
              </div>

              <div class="row gx-2">
                <div class="mb-3 col-md-6">
                  <label for="origin_price" class="form-label">原價</label>
                  <input type="number" class="form-control" id="origin_price"
                          placeholder="請輸入原價"
                          v-model.number="tempProduct.origin_price">
                </div>
                <div class="mb-3 col-md-6">
                  <label for="price" class="form-label">售價</label>
                  <input type="number" class="form-control" id="price"
                          placeholder="請輸入售價"
                          v-model.number="tempProduct.price">
                </div>
              </div>
              <hr>

              <div class="mb-3">
                <label for="description" class="form-label">產品描述</label>
                <textarea type="text" class="form-control" id="description"
                          placeholder="請輸入產品描述"
                          v-model="tempProduct.description"></textarea>
              </div>
              <div class="mb-3">
                <label for="content" class="form-label">說明內容</label>
                <textarea type="text" class="form-control" id="content"
                          placeholder="請輸入產品說明內容"
                          v-model="tempProduct.content"></textarea>
              </div>
              <div class="mb-3">
                <div class="form-check">
                  <input class="form-check-input" type="checkbox"
                          :true-value="1"
                          :false-value="0"
                          id="is_enabled"
                          v-model="tempProduct.is_enabled">
                  <label class="form-check-label" for="is_enabled">
                    是否啟用
                  </label>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-outline-secondary"
                  data-bs-dismiss="modal">取消
          </button>
          <button type="button" class="btn btn-primary"
          @click="$emit('update-product', tempProduct)">確認</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
// 載入 Modal
import Modal from 'bootstrap/js/dist/modal';

export default {
  // Props 向內層元件傳遞資料狀態
  props: {
    product: {
      type: Object,
      default() { return {}; },
    },
  },
  // 監聽
  watch: {
    product() {
      this.tempProduct = this.product;
      // 多圖範例
      if (!this.tempProduct.images) {
        this.tempProduct.images = [];
      }
    },
  },
  data() {
    return {
      // 實體內容回傳出來
      // modal 變數定義為物件
      modal: {},
      tempProduct: {},
    };
  },
  // 方法
  methods: {
    showModal() {
      this.modal.show();
    },
    hideModal() {
      this.modal.hide();
    },
    uploadFile() {
      // 取出上傳的檔案
      const uploadFile = this.$refs.fileInput.files[0];
      // console.dir(uploadFile);
      // 轉成 form-data 格式
      const formData = new FormData();
      formData.append('file-to-upload', uploadFile);
      // 透過 API 形式發送到遠端
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/upload`;
      this.$http.post(url, formData).then((response) => {
        // console.log(response.data);
        if (response.data.success) {
          this.tempProduct.imageUrl = response.data.imageUrl;
        }
      });
    },
  },
  // 實體必須在元件載入之後才能正確運作
  // mounted 生命週期
  mounted() {
    this.modal = new Modal(this.$refs.modal);
  },
};
</script>

使用 mixin 整合相同程式碼

  1. 元件裡面有相同的程式碼要怎麼樣進行合併
  2. 試著完成 delModal 功能
  3. DelModal.vue 和 ProductModal.vue 相同片段的程式碼
    使用 vue 的特性 mixin 把相同的程式碼抽離出來
  4. 在 src 新增資料夾 mixins
    在 mixins 新增檔案 modalMixin.js
  5. 抽離 DelModal.vue 檔案相同程式碼的部分到 modalMixin.js 檔案
  6. 在 DelModal.vue 檔案匯入 modalMixin.js 檔案
    在元件加入 mixins 屬性,是一個陣列
    mixins: [modalMixin]
  7. 使用相同的方式把 ProductModal.vue 檔案相同的程式碼也抽離出來
// components/DelModal.vue - 2. 試著完成 delModal 功能
<template>
  <div class="modal fade" id="delModal" tabindex="-1" role="dialog"
  aria-labelledby="exampleModalLabel" aria-hidden="true" ref="modal">
    <div class="modal-dialog" role="document">
      <div class="modal-content border-0">
        <div class="modal-header bg-danger text-white">
          <h5 class="modal-title">
            <span>刪除 {{ item.title }}</span>
          </h5>
          <button type="button" class="btn-close"
          data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          是否刪除 <strong class="text-danger">{{ item.title }}</strong> (刪除後將無法恢復)。
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-outline-secondary"
          data-bs-dismiss="modal">取消
          </button>
          <button type="button" class="btn btn-danger"
          @click="$emit('del-item')">確認刪除
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import Modal from 'bootstrap/js/dist/modal';

export default {
  props: {
    item: {},
  },
  data() {
    return {
      modal: '',
    };
  },
  methods: {
    showModal() {
      this.modal.show();
    },
    hideModal() {
      this.modal.hide();
    },
  },
  mounted() {
    this.modal = new Modal(this.$refs.modal);
  },
};
</script>
// mixins/modalMixin.js
import Modal from 'bootstrap/js/dist/modal';

export default {
  methods: {
    showModal() {
      this.modal.show();
    },
    hideModal() {
      this.modal.hide();
    },
  },
  mounted() {
    this.modal = new Modal(this.$refs.modal);
  },
};
// components/DelModal.vue
<template>
  <div class="modal fade" id="delModal" tabindex="-1" role="dialog"
  aria-labelledby="exampleModalLabel" aria-hidden="true" ref="modal">
    <div class="modal-dialog" role="document">
      <div class="modal-content border-0">
        <div class="modal-header bg-danger text-white">
          <h5 class="modal-title">
            <span>刪除 {{ item.title }}</span>
          </h5>
          <button type="button" class="btn-close"
          data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          是否刪除 <strong class="text-danger">{{ item.title }}</strong> (刪除後將無法恢復)。
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-outline-secondary"
          data-bs-dismiss="modal">取消
          </button>
          <button type="button" class="btn btn-danger"
          @click="$emit('del-item')">確認刪除
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import modalMixin from '@/mixins/modalMixin';

export default {
  props: {
    item: {},
  },
  data() {
    return {
      modal: '',
    };
  },
  // 使用 mixins 整合相同程式碼
  mixins: [modalMixin],
};
</script>
// components/ProductModal.vue
<template>
  <!-- Modal -->
  <div class="modal fade" id="exampleModal" tabindex="-1"
  aria-labelledby="exampleModalLabel" aria-hidden="true"
  ref="modal">
    <!-- 請同學自行新增 v-model -->
    <div class="modal-dialog modal-xl" role="document">
      <div class="modal-content border-0">
        <div class="modal-header bg-dark text-white">
          <h5 class="modal-title" id="exampleModalLabel">
            <span>新增產品</span>
          </h5>
          <button type="button" class="btn-close"
                  data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <div class="row">
            <div class="col-sm-4">
              <div class="mb-3">
                <label for="image" class="form-label">輸入圖片網址</label>
                <input type="text" class="form-control" id="image"
                        placeholder="請輸入圖片連結"
                        v-model="tempProduct.imageUrl">
              </div>
              <div class="mb-3">
                <label for="customFile" class="form-label">或 上傳圖片
                  <i class="fas fa-spinner fa-spin"></i>
                </label>
                <input type="file" id="customFile" class="form-control"
                ref="fileInput"
                @change="uploadFile">
              </div>
              <img class="img-fluid" :src="tempProduct.imageUrl" alt="">
              <!-- 延伸技巧,多圖 -->
              <div class="mt-5" v-if="tempProduct.images">
                <div class="mb-3 input-group" v-for="(image, key) in tempProduct.images" :key="key">
                  <input type="url" class="form-control form-control"
                          placeholder="請輸入連結"
                          v-model="tempProduct.images[key]">
                  <button type="button" class="btn btn-outline-danger"
                  @click="tempProduct.images.splice(key, 1)">
                    移除
                  </button>
                </div>
                <div v-if="tempProduct.images[tempProduct.images.length - 1]
                || !tempProduct.images.length">
                  <button class="btn btn-outline-primary btn-sm d-block w-100"
                  @click="tempProduct.images.push('')">
                    新增圖片
                  </button>
                </div>
              </div>
            </div>
            <div class="col-sm-8">
              <div class="mb-3">
                <label for="title" class="form-label">標題</label>
                <input type="text" class="form-control" id="title"
                        placeholder="請輸入標題"
                        v-model="tempProduct.title">
              </div>

              <div class="row gx-2">
                <div class="mb-3 col-md-6">
                  <label for="category" class="form-label">分類</label>
                  <input type="text" class="form-control" id="category"
                          placeholder="請輸入分類"
                          v-model="tempProduct.category">
                </div>
                <div class="mb-3 col-md-6">
                  <label for="price" class="form-label">單位</label>
                  <input type="text" class="form-control" id="unit"
                          placeholder="請輸入單位"
                          v-model="tempProduct.unit">
                </div>
              </div>

              <div class="row gx-2">
                <div class="mb-3 col-md-6">
                  <label for="origin_price" class="form-label">原價</label>
                  <input type="number" class="form-control" id="origin_price"
                          placeholder="請輸入原價"
                          v-model.number="tempProduct.origin_price">
                </div>
                <div class="mb-3 col-md-6">
                  <label for="price" class="form-label">售價</label>
                  <input type="number" class="form-control" id="price"
                          placeholder="請輸入售價"
                          v-model.number="tempProduct.price">
                </div>
              </div>
              <hr>

              <div class="mb-3">
                <label for="description" class="form-label">產品描述</label>
                <textarea type="text" class="form-control" id="description"
                          placeholder="請輸入產品描述"
                          v-model="tempProduct.description"></textarea>
              </div>
              <div class="mb-3">
                <label for="content" class="form-label">說明內容</label>
                <textarea type="text" class="form-control" id="content"
                          placeholder="請輸入產品說明內容"
                          v-model="tempProduct.content"></textarea>
              </div>
              <div class="mb-3">
                <div class="form-check">
                  <input class="form-check-input" type="checkbox"
                          :true-value="1"
                          :false-value="0"
                          id="is_enabled"
                          v-model="tempProduct.is_enabled">
                  <label class="form-check-label" for="is_enabled">
                    是否啟用
                  </label>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-outline-secondary"
                  data-bs-dismiss="modal">取消
          </button>
          <button type="button" class="btn btn-primary"
          @click="$emit('update-product', tempProduct)">確認</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import modalMixin from '@/mixins/modalMixin';

export default {
  // Props 向內層元件傳遞資料狀態
  props: {
    product: {
      type: Object,
      default() { return {}; },
    },
  },
  // 監聽
  watch: {
    product() {
      this.tempProduct = this.product;
      // 多圖範例
      if (!this.tempProduct.images) {
        this.tempProduct.images = [];
      }
    },
  },
  data() {
    return {
      // 實體內容回傳出來
      // modal 變數定義為物件
      modal: {},
      tempProduct: {},
    };
  },

  // 使用 mixinx 整合相同程式碼
  mixins: [modalMixin],
  // 方法
  methods: {
    uploadFile() {
      // 取出上傳的檔案
      const uploadFile = this.$refs.fileInput.files[0];
      // console.dir(uploadFile);
      // 轉成 form-data 格式
      const formData = new FormData();
      formData.append('file-to-upload', uploadFile);
      // 透過 API 形式發送到遠端
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/upload`;
      this.$http.post(url, formData).then((response) => {
        // console.log(response.data);
        if (response.data.success) {
          this.tempProduct.imageUrl = response.data.imageUrl;
        }
      });
    },
  },
};
</script>

加入讀取的視覺效果

  1. 使用 vue3-loading-overlay 套件
  2. 安裝 vue3-loading-overlay 套件
    npm install vue3-loading-overlay
  3. 查看文件是如何使用讀取效果
  4. 載入元件 – Import component
    複製 vue3-loading-over Usage 的程式碼
    打開 main.js 檔案,然後貼上程式碼
    修改程式碼路徑
    註冊元件,使用全域註冊方式啟用元件
  5. 在 Products.vue 檔案把 Loading 元件加到最上方
    加上 active 狀態,這是一個 props,實際上會把自定義的狀態給傳進去,自定義狀態名稱
    把狀態加到 data() 裡面
  6. 預期在取得產品列表的時候將讀取的效果顯示
    在 getProducts() 將讀取的效果加上
  7. 可以為所有的 AJAX 行為都加上讀取效果,包含登入、註冊、新增產品、編輯、刪除
// src/main.js
import { createApp } from 'vue';
import axios from 'axios';
import VueAxios from 'vue-axios';
import Loading from 'vue3-loading-overlay';
import 'vue3-loading-overlay/dist/vue3-loading-overlay.css';
import App from './App.vue';
import router from './router';

const app = createApp(App);
app.use(VueAxios, axios);
app.use(router);
app.component('Loading', Loading);
app.mount('#app');
// views/Products.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="text-end mt-3">
    <button class="btn btn-primary" type="button"
    @click="openModal(true)">
      增加一個產品
    </button>
  </div>
  <table class="table mt-4">
    <thead>
      <tr>
        <th width="120">分類</th>
        <th>產品名稱</th>
        <th width="120">原價</th>
        <th width="120">售價</th>
        <th width="100">是否啟用</th>
        <th width="200">編輯</th>
      </tr>
    </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 class="text-success" v-if="item.is_enabled">啟用</span>
          <span class="text-muted" v-else>未啟用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm"
            @click="openModal(false, item)">編輯</button>
            <button class="btn btn-outline-danger btn-sm"
            @click="openDelProductModal(item)">刪除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
  <ProductModal ref="productModal"
  :product="tempProduct"
  @update-product="updateProduct"></ProductModal>
  <DelModal ref="delModal"
  :item="tempProduct"
  @del-item="delProduct"></DelModal>
</template>

<script>
// 載入 ProductModal
import ProductModal from '@/components/ProductModal.vue';
import DelModal from '@/components/DelModal.vue';

export default {
  data() {
    return {
      // 產品資訊
      products: [],
      // 分頁資訊
      pagination: {},
      tempProduct: {},
      // 目前狀態
      isNew: false,
      // 讀取狀態
      isLoading: false,
    };
  },
  // 定義 components 進行區域註冊
  components: {
    ProductModal,
    DelModal,
  },
  methods: {
    // 取得產品列表是多數的產品資訊
    getProducts() {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products`;
      // 讀取效果
      this.isLoading = true;
      this.$http.get(api)
        .then((res) => {
          this.isLoading = false;
          if (res.data.success) {
            console.log(res.data);
            this.products = res.data.products;
            this.pagination = res.data.pagination;
          }
        });
    },
    openModal(isNew, item) {
      // console.log(isNew, item);
      // 流程判斷
      if (isNew) {
        this.tempProduct = {};
      } else {
        // 使用展開的形式取出資料
        this.tempProduct = { ...item };
      }
      this.isNew = isNew;
      const productComponent = this.$refs.productModal;
      productComponent.showModal();
    },
    updateProduct(item) {
      // 測試 tempProduct 參數有沒有正確傳過來
      // console.log(item);
      this.tempProduct = item;
      // 新增
      let api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product`;
      let httpMethod = 'post';
      // 編輯
      if (!this.isNew) {
        api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${item.id}`;
        httpMethod = 'put';
      }
      const productComponent = this.$refs.productModal;
      this.$http[httpMethod](api, { data: this.tempProduct })
        .then((response) => {
          console.log(response);
          // modal 關閉
          productComponent.hideModal();
          // 重新取得列表資料
          this.getProducts();
        });
    },
    // 開啟刪除 Modal
    openDelProductModal(item) {
      this.tempProduct = { ...item };
      const delComponent = this.$refs.delModal;
      delComponent.showModal();
    },
    delProduct() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${this.tempProduct.id}`;
      this.$http.delete(url).then((response) => {
        console.log(response.data);
        const delComponent = this.$refs.delModal;
        delComponent.hideModal();
        this.getProducts();
      });
    },
  },
  created() {
    this.getProducts();
  },
};
</script>

加入錯誤的訊息回饋

  1. 錯誤通知算是相對複雜的章節
    主要原因會牽動到非常多的檔案的互相溝通
  2. 使用 Bootstrap 吐司 (Toasts) 元件
    元件 > 吐司 > 方法
  3. 透過講解的形式了解吐司元件是怎麼運作
    吐司元件跟 Vue 元件如何互動
    目的是讓所有的元件都可以使用這個通知的功能
    通知功能不會只掛載在特定的元件下,會獨立放在任何可以呼叫到的地方,是獨立元件
    可以堆疊產生多個通知
    分離可以產生堆疊,每個吐司有自己獨立的生命週期,啟用的時候可以使用參數
    產品列表與吐司元件可以使用到 mitt 套件,進行跨元件的溝通
  4. 原始碼是如何運作
    安裝 mitt 套件,功能為跨元件溝通使用
    新增 emitter.js 檔案、撰寫程式碼
    在元件利用的時候可以只加在最外層,在 Dashboard 檔案匯入 emitter,使用 provide 讓內層的元件都可以使用外層功能
    在 Products.vue 檔案使用 inject
    在 ToastMessages.vue 檔案使用 inject
  5. 新增 ToastMessages.vue 檔案以及程式碼內容
    作為定位使用、列表呈現
    在 mounted() 生命週期加上 emitter 事件
  6. 新增 Toast.vue 檔案以及程式碼內容
    吐司元件
    從 Bootstrap 文件複製程式碼
    最重要的地方每次吐司元件生成的時候,觸發屬於自己生命周期的時候,吐司元件會開啟 6 秒鐘後消失
  7. Products 如何把訊息傳給吐司元件
    如何透過 mitt 送到吐司列表裡面
    在 Products.vue 檔案撰寫程式碼,根據判斷狀態推送不同的訊息內容
  8. 可以參考課程範例逐步拆解流程該怎麼樣運作
// src/methods/emitter.js
import mitt from 'mitt';

const emitter = mitt();

export default emitter;
// views/Dashboard.vue
<template>
  <Navbar></Navbar>
  <div class="container-fluid mt-3 position-relative">
    <ToastMessages></ToastMessages>
    <router-view/>
  </div>
</template>

<script>
import emitter from '@/methods/emitter';
import ToastMessages from '@/components/ToastMessages.vue';
import Navbar from '../components/Navbar.vue';

export default {
  components: {
    Navbar,
    ToastMessages,
  },
  // provide
  provide() {
    return {
      emitter,
    };
  },
  created() {
    // 取出 token
    // test2 改寫成 hexToken
    // Airbnb 規範把錯誤的 \ 去除
    const token = document.cookie.replace(/(?:(?:^|.*;\s*)hexToken\s*=\s*([^;]*).*$)|^.*$/, '$1');
    // console.log(token);
    // token 夾帶到 headers 裡面
    // ["Authorization"] 改寫成 .Authorization
    this.$http.defaults.headers.common.Authorization = token;
    // 檢查用戶是否仍然持續登入
    const api = `${process.env.VUE_APP_API}api/user/check`;
    this.$http.post(api)
      .then((res) => {
        // console.log(res);
        // 登入判斷 - 登入失敗
        if (!res.data.success) {
          // 登入失敗後會轉址到 Login 頁面
          this.$router.push('/login');
        }
      });
  },
};
</script>
// views/Products.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="text-end mt-3">
    <button class="btn btn-primary" type="button"
    @click="openModal(true)">
      增加一個產品
    </button>
  </div>
  <table class="table mt-4">
    <thead>
      <tr>
        <th width="120">分類</th>
        <th>產品名稱</th>
        <th width="120">原價</th>
        <th width="120">售價</th>
        <th width="100">是否啟用</th>
        <th width="200">編輯</th>
      </tr>
    </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 class="text-success" v-if="item.is_enabled">啟用</span>
          <span class="text-muted" v-else>未啟用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm"
            @click="openModal(false, item)">編輯</button>
            <button class="btn btn-outline-danger btn-sm"
            @click="openDelProductModal(item)">刪除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
  <ProductModal ref="productModal"
  :product="tempProduct"
  @update-product="updateProduct"></ProductModal>
  <DelModal ref="delModal"
  :item="tempProduct"
  @del-item="delProduct"></DelModal>
</template>

<script>
// 載入 ProductModal
import ProductModal from '@/components/ProductModal.vue';
import DelModal from '@/components/DelModal.vue';

export default {
  data() {
    return {
      // 產品資訊
      products: [],
      // 分頁資訊
      pagination: {},
      tempProduct: {},
      // 目前狀態
      isNew: false,
      // 讀取狀態
      isLoading: false,
    };
  },
  // inject
  inject: ['emitter'],
  // 定義 components 進行區域註冊
  components: {
    ProductModal,
    DelModal,
  },
  methods: {
    // 取得產品列表是多數的產品資訊
    getProducts() {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products`;
      // 讀取效果
      this.isLoading = true;
      this.$http.get(api)
        .then((res) => {
          this.isLoading = false;
          if (res.data.success) {
            console.log(res.data);
            this.products = res.data.products;
            this.pagination = res.data.pagination;
          }
        });
    },
    openModal(isNew, item) {
      // console.log(isNew, item);
      // 流程判斷
      if (isNew) {
        this.tempProduct = {};
      } else {
        // 使用展開的形式取出資料
        this.tempProduct = { ...item };
      }
      this.isNew = isNew;
      const productComponent = this.$refs.productModal;
      productComponent.showModal();
    },
    updateProduct(item) {
      // 測試 tempProduct 參數有沒有正確傳過來
      // console.log(item);
      this.tempProduct = item;
      // 新增
      let api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product`;
      let httpMethod = 'post';
      // 編輯
      if (!this.isNew) {
        api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${item.id}`;
        httpMethod = 'put';
      }
      const productComponent = this.$refs.productModal;
      this.$http[httpMethod](api, { data: this.tempProduct })
        .then((response) => {
          console.log(response);
          // modal 關閉
          productComponent.hideModal();
          // 判斷狀態推送不同的訊息內容
          if (response.data.success) {
            this.getProducts();
            this.emitter.emit('push-message', {
              style: 'success',
              title: '更新成功',
            });
          } else {
            this.emitter.emit('push-message', {
              style: 'danger',
              title: '更新失敗',
              content: response.data.message.join('、'),
            });
          }
        });
    },
    // 開啟刪除 Modal
    openDelProductModal(item) {
      this.tempProduct = { ...item };
      const delComponent = this.$refs.delModal;
      delComponent.showModal();
    },
    delProduct() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${this.tempProduct.id}`;
      this.$http.delete(url).then((response) => {
        console.log(response.data);
        const delComponent = this.$refs.delModal;
        delComponent.hideModal();
        this.getProducts();
      });
    },
  },
  created() {
    this.getProducts();
  },
};
</script>
// src/components/ToastMessages.vue - 彈出訊息列表元件
<template>
  <div class="toast-container position-absolute pe-3 top-0 end-0">
    <Toast v-for="(msg, key) in messages" :key="key"
      :msg="msg"
    />
  </div>
</template>

<script>
import Toast from '@/components/Toast.vue';

export default {
  components: { Toast },
  data() {
    return {
      messages: [],
    };
  },
  inject: ['emitter'],
  mounted() {
    // 請自行補上 emitter 事件
    this.emitter.on('push-message', (message) => {
      const { style = 'success', title, content } = message;
      this.messages.push({ style, title, content });
    });
  },
};
</script>
// src/components/Toast.vue - 吐司元件
<template>
  <div class="toast" role="alert" aria-live="assertive" aria-atomic="true" ref="toast">
    <div class="toast-header">
      <span :class="`bg-${msg.style}`" class="p-2 rounded me-2 d-inline-block"></span>
      <strong class="me-auto">{{ msg.title }}</strong>
      <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
    </div>
    <div class="toast-body" v-if="msg.content">
      {{ msg.content }}
    </div>
  </div>
</template>
<script>
import Toast from 'bootstrap/js/dist/toast';

export default {
  name: 'Toast',
  props: [
    'msg',
  ],
  mounted() {
    const toastEl = this.$refs.toast;
    const toast = new Toast(toastEl, {
      delay: 6000,
    });
    toast.show();
  },
};
</script>

加入分頁切換

  1. 分頁會使用元件的方式製作
    在之後的訂單、優惠券也可使用分頁的功能
  2. 關於分頁是怎麼運作
    透過 API 形式進行頁面的切換
    查看管理控制台 [需驗證] 文件 > 取得商品列表
  3. 示範直接透過分頁的參數切換頁面
    在 Products.vue 檔案 getProduct() 直接加入參數切換
  4. 在 getProduct() 加上參數預設值的形式 page = 1
    api 路徑就可以把變數載進來
    查看 Console 顯示的訊息 pagination、products
    將後端傳來製作分頁所需要的重要資訊製作成元件
  5. 新增 Pagination.vue 檔案和撰寫分頁元件程式碼
    關於指令、JS 自己練習撰寫
  6. 在 Products.vue 檔案加入 <Pagination> 標籤
    在外層定義 Products 元件,在內層定義 Pagination 元件,把分頁所需要的資訊傳進去,使用 props 的形式把分頁的資訊傳進去 props:pagination,就是從 AJAX 娶回來的相關資訊。在點擊分頁的時候會觸發 emit,執行頁面切換使用,會回傳 emits 觸發 getProduct 事件、同時把頁碼帶進來,透過這種方式進行頁面的切換
  7. 在 Pagination.vue 檔案預期外面會傳入一個 pages,這個就是 pagination,刻意用不同的名稱來代表,在這裡會傳入一個 pagination,是從 AJAX 所取回來的資料
  8. 在 Products.vue 檔案
    在產品頁面的 getProducts 之後是有把 pagination 的資訊存起來,存起來的內容可以在 <Pagination> 標籤帶進來,重點: 前內後外的概念,前面放入內層所需要接收的資訊,外層帶入 pagination
  9. 在 Pagination 檔案把頁碼加到這個區塊
    在 AJAX 所取回來的資料 pagination 裡面有 total_pages 變數,就可以把變數帶進來,使用 v-for 的形式、page in pages 從外層帶進來的 props 名稱 pages 然後找到 total_pages,使用 v-for 要再加上 key,帶入的是 page 名稱,在 <a> 連結把 page 帶進來
  10. 預期點下按鈕時會切換頁面
    使用 @click.prevent 加上一個自定義事件的名稱 updatePage 並且把頁碼帶進去
    調整一下函式的內容,會直接對外發送切換頁面的事件
    this.$emit(’emit-pages’, page)
  11. 在 Products.vue 檔案做主要的切換,預期會透過 emit 事件把事件往外送,在 <Pagination> 標籤加上由前內後外,內層事件名稱,預期切換 Products、觸發 getProducts 的事件,外層 getProducts 事件
  12. 在 Pagination.vue 檔案加上 active 樣式判斷
  13. 上一頁、下一頁自行製作
// src/components/Pagination.vue
<template>
  <nav aria-label="Page navigation example">
    <ul class="pagination justify-content-center">
      <li class="page-item" :class="{ 'disabled': !pages.has_pre }">
        <a class="page-link" href="#" aria-label="Previous"
        @click.prevent="updatePage(pages.current_page - 1)">
          <span aria-hidden="true">&laquo;</span>
        </a>
      </li>
      <li class="page-item"
      v-for="page in pages.total_pages"
      :key="page"
      :class="{ 'active': page === pages.current_page }">
        <a class="page-link" href="#"
        @click.prevent="updatePage(page)">
          {{ page }}
        </a>
      </li>
      <li class="page-item" :class="{ 'disabled': !pages.has_next }">
        <a class="page-link" href="#" aria-label="Next"
        @click.prevent="updatePage(pages.current_page + 1)">
          <span aria-hidden="true">&raquo;</span>
        </a>
      </li>
    </ul>
  </nav>
</template>

<script>
// :pages="{ 頁碼資訊 }"
// @emitPages="更新頁面事件"
export default {
  props: ['pages'],
  methods: {
    updatePage(page) {
      this.$emit('emit-pages', page);
    },
  },
};
</script>
// views/Products.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="text-end mt-3">
    <button class="btn btn-primary" type="button"
    @click="openModal(true)">
      增加一個產品
    </button>
  </div>
  <table class="table mt-4">
    <thead>
      <tr>
        <th width="120">分類</th>
        <th>產品名稱</th>
        <th width="120">原價</th>
        <th width="120">售價</th>
        <th width="100">是否啟用</th>
        <th width="200">編輯</th>
      </tr>
    </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 class="text-success" v-if="item.is_enabled">啟用</span>
          <span class="text-muted" v-else>未啟用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm"
            @click="openModal(false, item)">編輯</button>
            <button class="btn btn-outline-danger btn-sm"
            @click="openDelProductModal(item)">刪除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
  <Pagination :pages="pagination"
  @emit-pages="getProducts"></Pagination>
  <ProductModal ref="productModal"
  :product="tempProduct"
  @update-product="updateProduct"></ProductModal>
  <DelModal ref="delModal"
  :item="tempProduct"
  @del-item="delProduct"></DelModal>
</template>

<script>
// 載入 ProductModal
import ProductModal from '@/components/ProductModal.vue';
import DelModal from '@/components/DelModal.vue';
import Pagination from '@/components/Pagination.vue';

export default {
  data() {
    return {
      // 產品資訊
      products: [],
      // 分頁資訊
      pagination: {},
      tempProduct: {},
      // 目前狀態
      isNew: false,
      // 讀取狀態
      isLoading: false,
    };
  },
  // inject
  inject: ['emitter'],
  // 定義 components 進行區域註冊
  components: {
    ProductModal,
    DelModal,
    Pagination,
  },
  methods: {
    // 取得產品列表是多數的產品資訊
    getProducts(page = 1) {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products?page=${page}`;
      // 讀取效果
      this.isLoading = true;
      this.$http.get(api)
        .then((res) => {
          this.isLoading = false;
          if (res.data.success) {
            console.log(res.data);
            this.products = res.data.products;
            this.pagination = res.data.pagination;
          }
        });
    },
    openModal(isNew, item) {
      // console.log(isNew, item);
      // 流程判斷
      if (isNew) {
        this.tempProduct = {};
      } else {
        // 使用展開的形式取出資料
        this.tempProduct = { ...item };
      }
      this.isNew = isNew;
      const productComponent = this.$refs.productModal;
      productComponent.showModal();
    },
    updateProduct(item) {
      // 測試 tempProduct 參數有沒有正確傳過來
      // console.log(item);
      this.tempProduct = item;
      // 新增
      let api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product`;
      let httpMethod = 'post';
      // 編輯
      if (!this.isNew) {
        api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${item.id}`;
        httpMethod = 'put';
      }
      const productComponent = this.$refs.productModal;
      this.$http[httpMethod](api, { data: this.tempProduct })
        .then((response) => {
          console.log(response);
          // modal 關閉
          productComponent.hideModal();
          // 判斷狀態推送不同的訊息內容
          if (response.data.success) {
            this.getProducts();
            this.emitter.emit('push-message', {
              style: 'success',
              title: '更新成功',
            });
          } else {
            this.emitter.emit('push-message', {
              style: 'danger',
              title: '更新失敗',
              content: response.data.message.join('、'),
            });
          }
        });
    },
    // 開啟刪除 Modal
    openDelProductModal(item) {
      this.tempProduct = { ...item };
      const delComponent = this.$refs.delModal;
      delComponent.showModal();
    },
    delProduct() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${this.tempProduct.id}`;
      this.$http.delete(url).then((response) => {
        console.log(response.data);
        const delComponent = this.$refs.delModal;
        delComponent.hideModal();
        this.getProducts();
      });
    },
  },
  created() {
    this.getProducts();
  },
};
</script>

套用全域的千分號

  1. 數值超過一千以上建議加上千分號,在閱讀上面會比較好,在產品頁面、訂單、優惠券…等都有可能會加入千分號,會建議把千分號功能獨立起來,在每一個頁面、元件都可以使用
  2. 新增 filters.js 檔案
    加入千分號方法、轉換時間格式方法
  3. 在 Products.vue 檔案有 origin_price、price
    把函式加入轉換成千分號的形式
    匯入 filters.js 檔案,在 Products.vue 檔案只需要匯入 currency 方法
    將 currency 加到 methods 方法裡面就可以直接使用
    currency 加在金額的前方,因為 currency 本身是函式必須使用括號
  4. 額外的技巧,在 Vue3 官方文件有應用配置 > globalProperties,是全域的屬性
    可以使用 app.config.globalProperties 定義一個全域的屬性的方法,自定義任何想加入的屬性
    在每個子元件下都可以直接去使用 this 方式呼叫這個變數,如果是加入一般的純值效益其實不大,加入方法的話,就像千分號方法使用度就會高很多
  5. 實作額外的技巧
    在 main.js 檔案就可以把 filters.js 方法匯入
    參考官方文件 app.config 設定
    app.config.globalProperties 然後加上一個自定義的屬性名稱,使用 $filters 這個名稱作為一個集合,建議在屬性名稱最前方加上$,這樣比較不會跟區域元件裡面的變數產生衝突,等於一個物件,就可以把 currency 方法帶進來
  6. filters.js 其實有很多方法,可以陸續加入許多方法,都可以透過這種方式加到 filters 物件裡面
  7. 在 Products.vue 檔案,原本是直接呼叫 currency 方法,現在會在前面加上 $filters,可自定義屬性名稱
  8. 使用 $filters 這種方式加到全域的屬性,就必須把原本在區域的方式把移除
// methods/filters.js
export function currency(num) {
  const n = parseInt(num, 10);
  return `${n.toFixed(0).replace(/./g, (c, i, a) => (i && c !== '.' && ((a.length - i) % 3 === 0) ? `, ${c}`.replace(/\s/g, '') : c))}`;
}

export function date(time) {
  const localDate = new Date(time * 1000);
  return localDate.toLocaleDateString();
}
// views/Products.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="text-end mt-3">
    <button class="btn btn-primary" type="button"
    @click="openModal(true)">
      增加一個產品
    </button>
  </div>
  <table class="table mt-4">
    <thead>
      <tr>
        <th width="120">分類</th>
        <th>產品名稱</th>
        <th width="120">原價</th>
        <th width="120">售價</th>
        <th width="100">是否啟用</th>
        <th width="200">編輯</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="item in products" :key="item.id">
        <td>{{ item.category }}</td>
        <td>{{ item.title }}</td>
        <td class="text-right">
          {{ $filters.currency(item.origin_price) }}
        </td>
        <td class="text-right">
          {{ $filters.currency(item.price) }}
        </td>
        <td>
          <span class="text-success" v-if="item.is_enabled">啟用</span>
          <span class="text-muted" v-else>未啟用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm"
            @click="openModal(false, item)">編輯</button>
            <button class="btn btn-outline-danger btn-sm"
            @click="openDelProductModal(item)">刪除</button>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
  <Pagination :pages="pagination"
  @emit-pages="getProducts"></Pagination>
  <ProductModal ref="productModal"
  :product="tempProduct"
  @update-product="updateProduct"></ProductModal>
  <DelModal ref="delModal"
  :item="tempProduct"
  @del-item="delProduct"></DelModal>
</template>

<script>
// 載入 ProductModal
import ProductModal from '@/components/ProductModal.vue';
import DelModal from '@/components/DelModal.vue';
import Pagination from '@/components/Pagination.vue';
// import { currency } from '@/methods/filters';

export default {
  data() {
    return {
      // 產品資訊
      products: [],
      // 分頁資訊
      pagination: {},
      tempProduct: {},
      // 目前狀態
      isNew: false,
      // 讀取狀態
      isLoading: false,
    };
  },
  // inject
  inject: ['emitter'],
  // 定義 components 進行區域註冊
  components: {
    ProductModal,
    DelModal,
    Pagination,
  },
  methods: {
    // 取得產品列表是多數的產品資訊
    getProducts(page = 1) {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/products?page=${page}`;
      // 讀取效果
      this.isLoading = true;
      this.$http.get(api)
        .then((res) => {
          this.isLoading = false;
          if (res.data.success) {
            console.log(res.data);
            this.products = res.data.products;
            this.pagination = res.data.pagination;
          }
        });
    },
    openModal(isNew, item) {
      // console.log(isNew, item);
      // 流程判斷
      if (isNew) {
        this.tempProduct = {};
      } else {
        // 使用展開的形式取出資料
        this.tempProduct = { ...item };
      }
      this.isNew = isNew;
      const productComponent = this.$refs.productModal;
      productComponent.showModal();
    },
    updateProduct(item) {
      // 測試 tempProduct 參數有沒有正確傳過來
      // console.log(item);
      this.tempProduct = item;
      // 新增
      let api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product`;
      let httpMethod = 'post';
      // 編輯
      if (!this.isNew) {
        api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${item.id}`;
        httpMethod = 'put';
      }
      const productComponent = this.$refs.productModal;
      this.$http[httpMethod](api, { data: this.tempProduct })
        .then((response) => {
          console.log(response);
          // modal 關閉
          productComponent.hideModal();
          // 判斷狀態推送不同的訊息內容
          if (response.data.success) {
            this.getProducts();
            this.emitter.emit('push-message', {
              style: 'success',
              title: '更新成功',
            });
          } else {
            this.emitter.emit('push-message', {
              style: 'danger',
              title: '更新失敗',
              content: response.data.message.join('、'),
            });
          }
        });
    },
    // 開啟刪除 Modal
    openDelProductModal(item) {
      this.tempProduct = { ...item };
      const delComponent = this.$refs.delModal;
      delComponent.showModal();
    },
    delProduct() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/product/${this.tempProduct.id}`;
      this.$http.delete(url).then((response) => {
        console.log(response.data);
        const delComponent = this.$refs.delModal;
        delComponent.hideModal();
        this.getProducts();
      });
    },
    // currency,
  },
  created() {
    this.getProducts();
  },
};
</script>
// src/main.js
import { createApp } from 'vue';
import axios from 'axios';
import VueAxios from 'vue-axios';
import Loading from 'vue3-loading-overlay';
import 'vue3-loading-overlay/dist/vue3-loading-overlay.css';
import App from './App.vue';
import router from './router';
import { currency } from './methods/filters';

const app = createApp(App);
app.config.globalProperties.$filters = {
  currency,
};
app.use(VueAxios, axios);
app.use(router);
app.component('Loading', Loading);
app.mount('#app');

中場加速

  1. 完成訂單、優惠券的頁面
  2. 訂單 – 訂單列表、編輯訂單、刪除訂單、分頁功能
  3. 在 Products.vue 檔案
    在每次發送 http 行為的時候,都會針對事件成功與否觸發 emitter,emitter 就會把成功與否透過吐司的方式呈現,封裝方法就放在 pushMessageState.js
  4. 在 main.js 檔案加入全域的屬性
    app.config.globalProperties.$httpMessageState = $httpMessageState;
    加入後就可以在每個程式碼都可以直接呼叫這個方法
    注意: 這個方法是整合 AJAX 的一些錯誤事件,統一整理發送給 Toast 使用,正常來說不太建議把太多的方法掛在全域下面,會不知道這個方法來自於哪裡
    可以使用 provide 來處理
  5. 如何透過 API 完成一個作品
    下一個章節介紹客戶購物的部分
    如何完成查看特定產品、以及加到購物車的內容裡面
    選擇產品的圖片、撰寫產品的文案,並且把內容加到自行設計的介面
    如何製作購物車、包含購物車列表以及表單填寫的部分
// src/components/CouponModal.vue
<template>
  <div class="modal fade" id="couponModal" tabindex="-1" role="dialog"
  aria-labelledby="exampleModalLabel" aria-hidden="true" ref="modal">
    <div class="modal-dialog" role="document">
      <div class="modal-content">
        <div class="modal-header">
          <h5 class="modal-title" id="exampleModalLabel">Modal title</h5>
          <button type="button" class="btn-close"
          data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <div class="mb-3">
            <label for="title">標題</label>
            <input type="text" class="form-control" id="title" v-model="tempCoupon.title"
            placeholder="請輸入標題">
          </div>
          <div class="mb-3">
            <label for="coupon_code">優惠碼</label>
            <input type="text" class="form-control" id="coupon_code" v-model="tempCoupon.code"
            placeholder="請輸入優惠碼">
          </div>
          <div class="mb-3">
            <label for="due_date">到期日</label>
            <input type="date" class="form-control" id="due_date"
            v-model="due_date">
          </div>
          <div class="mb-3">
            <label for="price">折扣百分比</label>
            <input type="number" class="form-control" id="price"
            v-model.number="tempCoupon.percent" placeholder="請輸入折扣百分比">
          </div>
          <div class="mb-3">
            <div class="form-check">
              <input class="form-check-input" type="checkbox"
              :true-value="1"
              :false-value="0"
              v-model="tempCoupon.is_enabled" id="is_enabled">
              <label class="form-check-label" for="is_enabled">
                是否啟用
              </label>
            </div>
          </div>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
          <button type="button" class="btn btn-primary"
                  @click="$emit('update-coupon', tempCoupon)">更新優惠券
          </button>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
import modalMixin from '@/mixins/modalMixin';

export default {
  name: 'couponModal',
  props: {
    coupon: {},
  },
  data() {
    return {
      tempCoupon: {},
      due_date: '',
    };
  },
  emits: ['update-coupon'],
  watch: {
    coupon() {
      this.tempCoupon = this.coupon;
      // 將時間格式改為 YYYY-MM-DD
      console.log(this.tempCoupon.due_date);
      const dateAndTime = new Date(this.tempCoupon.due_date * 1000)
        .toISOString().split('T');
      [this.due_date] = dateAndTime;
    },
    due_date() {
      this.tempCoupon.due_date = Math.floor(new Date(this.due_date) / 1000);
    },
  },
  mixins: [modalMixin],
};
</script>
// src/components/Navbar.vue
<template>
  <nav class="navbar navbar-expand-lg bg-body-tertiary">
    <div class="container-fluid">
      <a class="navbar-brand" href="#">購物趣</a>
      <button class="navbar-toggler" type="button"
      data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup"
      aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarNavAltMarkup">
        <ul class="navbar-nav me-auto mb-2 mb-lg-0">
          <li class="nav-item">
            <router-link to="/dashboard/products" class="nav-link">產品</router-link>
          </li>
          <li class="nav-item">
            <router-link to="/dashboard/orders" class="nav-link">訂單</router-link>
          </li>
          <li class="nav-item">
            <router-link to="/dashboard/coupons" class="nav-link">優惠券</router-link>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="#" @click.prevent="logout">登出</a>
          </li>
        </ul>
        <span class="navbar-text">
          後台管理
        </span>
      </div>
    </div>
  </nav>
</template>

<script>
export default {
  methods: {
    logout() {
      const api = `${process.env.VUE_APP_API}logout`;
      this.$http.post(api, this.user)
        .then((res) => {
          if (res.data.success) {
            // console.log(res);
            this.$router.push('/login');
          }
        });
    },
  },
};
</script>
// src/components/OrderModal.vue
<template>
  <div class="modal fade" id="productModal" tabindex="-1" role="dialog"
  aria-labelledby="exampleModalLabel" aria-hidden="true" ref="modal">
    <div class="modal-dialog modal-xl" role="document">
      <div class="modal-content border-0">
        <div class="modal-header bg-dark text-white">
          <h5 class="modal-title" id="exampleModalLabel">
            <span>訂單細節</span>
          </h5>
          <button type="button" class="btn-close bg-light"
                  data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <div class="row">
            <div class="col-md-4">
              <h3>用戶資料</h3>
              <table class="table">
                <tbody v-if="tempOrder.user">
                  <tr>
                    <th style="width: 100px;">姓名</th>
                    <td>{{ tempOrder.user.name }}</td>
                  </tr>
                  <tr>
                    <th>Email</th>
                    <td>{{ tempOrder.user.email }}</td>
                  </tr>
                  <tr>
                    <th>電話</th>
                    <td>{{ tempOrder.user.tel }}</td>
                  </tr>
                  <tr>
                    <th>地址</th>
                    <td>{{ tempOrder.user.address }}</td>
                  </tr>
                </tbody>
              </table>
            </div>
            <div class="col-md-8">
              <h3>訂單細節</h3>
              <table class="table">
                <tbody>
                  <tr>
                    <th style="width: 100px">訂單編號</th>
                    <td>{{ tempOrder.id }}</td>
                  </tr>
                  <tr>
                    <th>下單時間</th>
                    <td>{{ $filters.date(tempOrder.create_at)}}</td>
                  </tr>
                  <tr>
                    <th>付款時間</th>
                    <td>
                      <span v-if="tempOrder.paid_date">
                        {{ $filters.date(tempOrder.paid_date) }}
                      </span>
                      <span v-else>時間不正確</span>
                    </td>
                  </tr>
                  <tr>
                    <th>付款狀態</th>
                    <td>
                      <strong v-if="tempOrder.is_paid" class="text-success">已付款</strong>
                      <span v-else class="text-muted">尚未付款</span>
                    </td>
                  </tr>
                  <tr>
                    <th>總金額</th>
                    <td>
                      {{ $filters.currency(tempOrder.total) }}
                    </td>
                  </tr>
                </tbody>
              </table>
              <h3>選購商品</h3>
              <table class="table">
                <thead>
                  <tr>
                  </tr>
                </thead>
                <tbody>
                  <tr v-for="item in tempOrder.products" :key="item.id">
                    <th>
                      {{ item.product.title }}
                    </th>
                    <td>
                      {{ item.qty }} / {{ item.product.unit }}
                    </td>
                    <td class="text-end">
                      {{ $filters.currency(item.final_total) }}
                    </td>
                  </tr>
                </tbody>
              </table>
            </div>
          </div>
        </div>
        <div class="modal-footer">
          <button type="button" class="btn btn-outline-secondary"
                  data-bs-dismiss="modal">取消
          </button>
          <button type="button" class="btn btn-primary"
                  @click="$emit('update-order', tempOrder)">確認</button>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
import modalMixin from '@/mixins/modalMixin';

export default {
  name: 'orderModal',
  props: {
    order: {
      type: Object,
      default() { return {}; },
    },
  },
  data() {
    return {
      status: {},
      modal: '',
      tempOrder: {},
      isPaid: false,
    };
  },
  emits: ['update-product'],
  mixins: [modalMixin],
  inject: ['emitter'],
  watch: {
    order() {
      this.tempOrder = this.order;
      this.isPaid = this.tempOrder.is_paid;
    },
  },
};
</script>
// src/main.js
import { createApp } from 'vue';
import axios from 'axios';
import VueAxios from 'vue-axios';
import Loading from 'vue3-loading-overlay';
import 'vue3-loading-overlay/dist/vue3-loading-overlay.css';
import App from './App.vue';
import router from './router';
import { currency, date } from './methods/filters';
import $httpMessageState from './methods/pushMessageState';

const app = createApp(App);
app.config.globalProperties.$filters = {
  currency,
  date,
};
// 此函式的用途是整合 AJAX 的錯誤事件,統一整理發送給予 Toast 處理
app.config.globalProperties.$httpMessageState = $httpMessageState;

app.use(VueAxios, axios);
app.use(router);
app.component('Loading', Loading);
app.mount('#app');
// src/methods/pushMessageState.js
import emitter from '@/methods/emitter';

export default function (response, title = '更新') {
  if (response.data.success) {
    emitter.emit('push-message', {
      style: 'success',
      title: `${title}成功`,
    });
  } else {
    // 有些訊息是字串,有些則是陣列,在此統一格式
    const message = typeof response.data.message === 'string'
      ? [response.data.message] : response.data.message;
    emitter.emit('push-message', {
      style: 'danger',
      title: `${title}失敗`,
      content: message.join('、'),
    });
  }
}
// src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    component: () => import('../views/Login.vue'),
  },
  {
    path: '/dashboard',
    component: () => import('../views/Dashboard.vue'),
    // 巢狀路由 - 產品列表
    children: [
      {
        path: 'products',
        component: () => import('../views/Products.vue'),
      },
      {
        path: 'orders',
        component: () => import('../views/Orders.vue'),
      },
      {
        path: 'coupons',
        component: () => import('../views/Coupons.vue'),
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
  linkActiveClass: 'active',
});

export default router;
// src/views/Coupons.vue
<template>
  <div>
    <Loading :active="isLoading"></Loading>
    <div class="text-end mt-4">
      <button class="btn btn-primary" @click="openCouponModal(true)">
        建立新的優惠券
      </button>
    </div>
    <table class="table mt-4">
      <thead>
      <tr>
        <th>名稱</th>
        <th>折扣百分比</th>
        <th>到期日</th>
        <th>是否啟用</th>
        <th>編輯</th>
      </tr>
      </thead>
      <tbody>
      <tr v-for="(item, key) in coupons" :key="key">
        <td>{{ item.title }}</td>
        <td>{{ item.percent }}%</td>
        <td>{{ $filters.date(item.due_date) }}</td>
        <td>
          <span v-if="item.is_enabled === 1" class="text-success">啟用</span>
          <span v-else class="text-muted">未起用</span>
        </td>
        <td>
          <div class="btn-group">
            <button class="btn btn-outline-primary btn-sm"
                    @click="openCouponModal(false, item)"
            >編輯</button>
            <button class="btn btn-outline-danger btn-sm"
                    @click="openDelCouponModal(item)"
            >刪除</button>
          </div>
        </td>
      </tr>
      </tbody>
    </table>
    <CouponModal :coupon="tempCoupon" ref="couponModal"
    @update-coupon="updateCoupon"></CouponModal>
    <DelModal :item="tempCoupon" ref="delModal" @del-item="delCoupon"/>
    <Pagination :pages="pagination" @emit-pages="getCoupons"></Pagination>
  </div>
</template>

<script>
import CouponModal from '@/components/CouponModal.vue';
import DelModal from '@/components/DelModal.vue';
import Pagination from '@/components/Pagination.vue';

export default {
  components: {
    CouponModal,
    DelModal,
    Pagination,
  },
  props: {
    config: Object,
  },
  data() {
    return {
      coupons: {},
      tempCoupon: {
        title: '',
        is_enabled: 0,
        percent: 100,
        code: '',
      },
      isLoading: false,
      isNew: false,
      pagination: {},
      currentPage: 1,
    };
  },
  methods: {
    openCouponModal(isNew, item) {
      this.isNew = isNew;
      if (this.isNew) {
        this.tempCoupon = {
          due_date: new Date().getTime() / 1000,
        };
      } else {
        this.tempCoupon = { ...item };
      }
      this.$refs.couponModal.showModal();
    },
    openDelCouponModal(item) {
      this.tempCoupon = { ...item };
      const delComponent = this.$refs.delModal;
      delComponent.showModal();
    },
    getCoupons(currentPage = 1) {
      this.currentPage = currentPage;
      this.isLoading = true;
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/coupons?page=${currentPage}`;
      this.$http.get(url, this.tempProduct).then((response) => {
        this.coupons = response.data.coupons;
        this.pagination = response.data.pagination;
        this.isLoading = false;
        console.log(response);
      });
    },
    updateCoupon(tempCoupon) {
      if (this.isNew) {
        const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/coupon`;
        this.$http.post(url, { data: tempCoupon }).then((response) => {
          console.log(response, tempCoupon);
          this.$httpMessageState(response, '新增優惠券');
          this.getCoupons();
          this.$refs.couponModal.hideModal();
        });
      } else {
        const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/coupon/${this.tempCoupon.id}`;
        this.$http.put(url, { data: this.tempCoupon }).then((response) => {
          console.log(response);
          this.$httpMessageState(response, '新增優惠券');
          this.getCoupons();
          this.$refs.couponModal.hideModal();
        });
      }
    },
    delCoupon() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/coupon/${this.tempCoupon.id}`;
      this.isLoading = true;
      this.$http.delete(url).then((response) => {
        console.log(response, this.tempCoupon);
        this.$httpMessageState(response, '刪除優惠券');
        const delComponent = this.$refs.delModal;
        delComponent.hideModal();
        this.getCoupons();
      });
    },
  },
  created() {
    this.getCoupons();
  },
};
</script>
// src/views/Orders.vue
<template>
  <Loading :active="isLoading"></Loading>
  <table class="table mt-4">
    <thead>
    <tr>
      <th>購買時間</th>
      <th>Email</th>
      <th>購買款項</th>
      <th>應付金額</th>
      <th>是否付款</th>
      <th>編輯</th>
    </tr>
    </thead>
    <tbody>
      <template v-for="(item, key) in orders" :key="key">
        <tr v-if="orders.length"
            :class="{'text-secondary': !item.is_paid}">
          <td>{{ $filters.date(item.create_at) }}</td>
          <td><span v-text="item.user.email" v-if="item.user"></span></td>
          <td>
            <ul class="list-unstyled">
              <li v-for="(product, i) in item.products" :key="i">
                {{ product.product.title }} 數量:{{ product.qty }}
                {{ product.product.unit }}
              </li>
            </ul>
          </td>
          <td class="text-right">{{ $filters.currency(item.total) }}</td>
          <td>
            <div class="form-check form-switch">
              <input class="form-check-input" type="checkbox" :id="`paidSwitch${item.id}`"
              v-model="item.is_paid"
              @change="updatePaid(item)">
              <label class="form-check-label" :for="`paidSwitch${item.id}`">
                <span v-if="item.is_paid">已付款</span>
                <span v-else>未付款</span>
              </label>
            </div>
          </td>
          <td>
            <div class="btn-group">
              <button class="btn btn-outline-primary btn-sm"
                      @click="openModal(false, item)">檢視</button>
              <button class="btn btn-outline-danger btn-sm"
                      @click="openDelOrderModal(item)"
              >刪除</button>
            </div>
          </td>
        </tr>
      </template>
    </tbody>
  </table>
  <OrderModal :order="tempOrder"
              ref="orderModal" @update-paid="updatePaid"></OrderModal>
  <DelModal :item="tempOrder" ref="delModal" @del-item="delOrder"></DelModal>
  <Pagination :pages="pagination" @emit-pages="getOrders"></Pagination>
</template>

<script>
import DelModal from '@/components/DelModal.vue';
import OrderModal from '@/components/OrderModal.vue';
import Pagination from '@/components/Pagination.vue';

export default {
  data() {
    return {
      orders: {},
      isNew: false,
      pagination: {},
      isLoading: false,
      tempOrder: {},
      currentPage: 1,
    };
  },
  components: {
    Pagination,
    DelModal,
    OrderModal,
  },
  methods: {
    getOrders(currentPage = 1) {
      this.currentPage = currentPage;
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/orders?page=${currentPage}`;
      this.isLoading = true;
      this.$http.get(url, this.tempProduct).then((response) => {
        this.orders = response.data.orders;
        this.pagination = response.data.pagination;
        this.isLoading = false;
        console.log(response);
      });
    },
    openModal(isNew, item) {
      this.tempOrder = { ...item };
      this.isNew = false;
      const orderComponent = this.$refs.orderModal;
      orderComponent.showModal();
    },
    openDelOrderModal(item) {
      this.tempOrder = { ...item };
      const delComponent = this.$refs.delModal;
      delComponent.showModal();
    },
    updatePaid(item) {
      this.isLoading = true;
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/order/${item.id}`;
      const paid = {
        is_paid: item.is_paid,
      };
      this.$http.put(api, { data: paid }).then((response) => {
        this.isLoading = false;
        this.getOrders(this.currentPage);
        this.$httpMessageState(response, '更新付款狀態');
      });
    },
    delOrder() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/admin/order/${this.tempOrder.id}`;
      this.isLoading = true;
      this.$http.delete(url).then((response) => {
        console.log(response);
        const delComponent = this.$refs.delModal;
        delComponent.hideModal();
        this.getOrders(this.currentPage);
      });
    },
  },
  created() {
    this.getOrders();
    console.log(process.env.VUE_APP_API);
  },
};
</script>

練習

  • 使用測試 Web API 應用程式 Postman、 Hoppscotch 建立優惠券和訂單
  • Vue3_API
    • POST 登入功能
    • POST 新增優惠券
    • GET 取得商品列表
    • POST 加入購物車
    • PUT 更新購物車
    • POST 結帳頁面
    • GET 取得訂單列表

用戶端產品列表

  1. 製作客戶購物的部分
    查看 API 文件 – 客戶購物 [免驗證]
    購物流程: 取得商品列表、單一商品細節、加入購物車、刪除某一筆購物車資料、套用優惠券、結帳頁面、結帳付款…等
  2. 提醒: 點選任何一個產品的時候,請用單一頁面呈現
  3. 在 index.js 路由表加上用戶端路由
    新增 user 路徑,不需要驗證流程會放在 user 路徑下
  4. 建立 Userboard.vue 檔案並加入程式碼
    相較於 Dashboard 較簡單
  5. 建立 UserCart.vue 檔案並加入程式碼
    在 user 路徑新增子路由 cart 路徑,對應的是購物車
    建立 UserProduct.vue 檔案並加入程式碼
    在 user 路徑新增子路由 product/:productId 路徑,對應的是單一產品頁面
  6. 講解 UserCart.vue 檔案
    methods 方法的函式,getProducts() 取得商品列表、getProduct(id) 進入單一商品的頁面
  7. 試著把用戶端商品列表呈現
// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    component: () => import('../views/Login.vue'),
  },
  {
    path: '/dashboard',
    component: () => import('../views/Dashboard.vue'),
    // 巢狀路由 - 產品列表
    children: [
      {
        path: 'products',
        component: () => import('../views/Products.vue'),
      },
      {
        path: 'orders',
        component: () => import('../views/Orders.vue'),
      },
      {
        path: 'coupons',
        component: () => import('../views/Coupons.vue'),
      },
    ],
  },
  {
    path: '/user',
    component: () => import('../views/Userboard.vue'),
    children: [
      {
        path: 'cart',
        component: () => import('../views/UserCart.vue'),
      },
      {
        path: 'product/:productId',
        component: () => import('@/views/UserProduct.vue'),
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
  linkActiveClass: 'active',
});

export default router;
// views/Userboard.vue
<template>
  <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
    <div class="container-fluid">
      <router-link class="navbar-brand" to="/user/cart">購物趣</router-link>
    </div>
  </nav>
  <div class="container-fluid mt-3 position-relative">
    <ToastMessages></ToastMessages>
    <router-view/>
  </div>
</template>

<script>
import emitter from '@/methods/emitter';
import ToastMessages from '@/components/ToastMessages.vue';

export default {
  components: {
    ToastMessages,
  },
  provide() {
    return {
      emitter,
    };
  },
  created() {
  },
};
</script>
// views/UserCart.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="container">
    <div class="row mt-4">
      <div class="col-md-7">
        <table class="table align-middle">
          <thead>
            <tr>
              <th>圖片</th>
              <th>商品名稱</th>
              <th>價格</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in products" :key="item.id">
              <td style="width: 200px">
                <div style="height: 100px; background-size: cover; background-position: center"
                :style="{backgroundImage: `url(${item.imageUrl})`}"></div>
              </td>
              <td><a href="#" class="text-dark">{{ item.title }}</a></td>
              <td>
                <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>
              </td>
              <td>
                <div class="btn-group btn-group-sm">
                  <button type="button" class="btn btn-outline-secondary"
                  @click="getProduct(item.id)">
                    查看更多
                  </button>
                  <button type="button" class="btn btn-outline-danger">
                    加到購物車
                  </button>
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
      <!-- 購物車列表 -->
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      products: [],
      product: {},
      status: {
        loadingItem: '',
      },
    };
  },
  methods: {
    // 取得商品列表
    getProducts() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/products/all`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        this.products = response.data.products;
        console.log('products:', response);
        this.isLoading = false;
      });
    },
    // 進入單一商品頁面
    getProduct(id) {
      this.$router.push(`/user/product/${id}`);
    },
  },
  created() {
    this.getProducts();
  },
};
</script>
// views/UserProduct.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="container">
    <nav aria-label="breadcrumb">
      <ol class="breadcrumb">
        <li class="breadcrumb-item"><router-link to="/user/cart">購物車</router-link></li>
        <li class="breadcrumb-item active" aria-current="page">{{ product.title }}</li>
      </ol>
    </nav>
    <div class="row justify-content-center">
      <article class="col-8">
        <h2>{{ product.title }}</h2>
        <div>{{ product.content }}</div>
        <div>{{ product.description }}</div>
        <img :src="product.imageUrl" alt="" class="img-fluid mb-3">
      </article>
      <div class="col-4">
        <div class="h5" v-if="!product.price">{{ product.origin_price }} 元</div>
        <del class="h6" v-if="product.price">原價 {{ product.origin_price }} 元</del>
        <div class="h5" v-if="product.price">現在只要 {{ product.price }} 元</div>
        <hr>
        <button type="button" class="btn btn-outline-danger"
                @click="addToCart(product.id)">
          加到購物車
        </button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      product: {},
      id: '',
    };
  },
  methods: {
    getProduct() {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/product/${this.id}`;
      this.isLoading = true;
      this.$http.get(api).then((response) => {
        console.log(response.data);
        this.isLoading = false;
        if (response.data.success) {
          this.product = response.data.product;
        }
      });
    },
    addToCart(id, qty = 1) {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      const cart = {
        product_id: id,
        qty,
      };
      this.isLoading = true;
      this.$http.post(url, { data: cart }).then((response) => {
        this.isLoading = false;
        this.$httpMessageState(response, '加入購物車');
        this.$router.push('/user/cart');
      });
    },
  },
  created() {
    this.id = this.$route.params.productId;
    this.getProduct();
  },
};
</script>

用戶端加入購物車

  1. 撰寫加入購物車的行為 api
  2. 查看客戶購物 [免驗證] 文件 > 加入購物車
  3. 在 UserCart.vue 檔案撰寫程式碼
    使用 @click 方式觸發加入購物車事件、addCart 方法,把產品的 id 帶進來
    在 JS methods 方法新增 addCart 方法,測試 id 有沒有正確的取出
    載入 api、建立資料 product_id, qty、發送 api
    資料參數格式有加入 data 屬性名稱
  4. 避免用戶重複點擊加入購物車,加入單一按鈕的讀取效果,當 loadingItem 為一個特定品項的時候按鈕會轉為 disabled 並加上讀取的狀態
  5. 調整 HTML 的部分加上
    :disabled=”this.status.loadingItem === item.id”
    這個屬性會因為 loadingItem 的品項跟當前 item.id 的品項相同的時候,就會把加入到購物車按鈕轉為 disabled 的狀態
  6. 針對點擊購物車後加上額外的讀取效果
    在 Bootstrap 文件 > Components > Spinners,選擇放射狀的樣式加到程式碼,尺寸需要調整一下選擇最小的、調整色彩
    當前品項加入判斷式,v-if=”this.status.loadingItem === item.id”
    白色對比度不佳,轉換成紅色
  7. 用戶無法重複點擊,了解目前是讀取狀態
    如何加入購物車、如何把特定的按鈕加上讀取的效果
  8. 試著加入購物車、特定頁面的加入購物車功能完成
// views/UserCart.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="container">
    <div class="row mt-4">
      <div class="col-md-7">
        <table class="table align-middle">
          <thead>
            <tr>
              <th>圖片</th>
              <th>商品名稱</th>
              <th>價格</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in products" :key="item.id">
              <td style="width: 200px">
                <div style="height: 100px; background-size: cover; background-position: center"
                :style="{backgroundImage: `url(${item.imageUrl})`}"></div>
              </td>
              <td><a href="#" class="text-dark">{{ item.title }}</a></td>
              <td>
                <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>
              </td>
              <td>
                <div class="btn-group btn-group-sm">
                  <button type="button" class="btn btn-outline-secondary"
                  @click="getProduct(item.id)">
                    查看更多
                  </button>
                  <button type="button" class="btn btn-outline-danger"
                  @click="addCart(item.id)"
                  :disabled="this.status.loadingItem === item.id">
                    <div v-if="this.status.loadingItem === item.id"
                    class="spinner-grow text-danger spinner-grow-sm" role="status">
                      <span class="visually-hidden">Loading...</span>
                    </div>
                    加到購物車
                  </button>
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
      <!-- 購物車列表 -->
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      products: [],
      product: {},
      // 讀取狀態
      status: {
        loadingItem: '', // 對應品項 id
      },
    };
  },
  methods: {
    // 取得商品列表
    getProducts() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/products/all`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        this.products = response.data.products;
        console.log('products:', response);
        this.isLoading = false;
      });
    },
    // 進入單一商品頁面
    getProduct(id) {
      this.$router.push(`/user/product/${id}`);
    },
    // 加入購物車
    addCart(id) {
      // 測試 id 有沒有正確取出
      // console.log(id);
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.status.loadingItem = id;
      const cart = {
        product_id: id,
        qty: 1,
      };
      this.$http.post(url, { data: cart })
        .then((res) => {
          this.status.loadingItem = '';
          console.log(res);
        });
    },
  },
  created() {
    this.getProducts();
  },
};
</script>
// views/UserProduct.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="container">
    <nav aria-label="breadcrumb">
      <ol class="breadcrumb">
        <li class="breadcrumb-item"><router-link to="/user/cart">購物車</router-link></li>
        <li class="breadcrumb-item active" aria-current="page">{{ product.title }}</li>
      </ol>
    </nav>
    <div class="row justify-content-center">
      <article class="col-8">
        <h2>{{ product.title }}</h2>
        <div>{{ product.content }}</div>
        <div>{{ product.description }}</div>
        <img :src="product.imageUrl" alt="" class="img-fluid mb-3">
      </article>
      <div class="col-4">
        <div class="h5" v-if="!product.price">{{ product.origin_price }} 元</div>
        <del class="h6" v-if="product.price">原價 {{ product.origin_price }} 元</del>
        <div class="h5" v-if="product.price">現在只要 {{ product.price }} 元</div>
        <hr>
        <button type="button" class="btn btn-outline-danger"
                @click="addToCart(product.id)">
          加到購物車
        </button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      product: {},
      id: '',
    };
  },
  methods: {
    getProduct() {
      const api = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/product/${this.id}`;
      this.isLoading = true;
      this.$http.get(api).then((response) => {
        console.log(response.data);
        this.isLoading = false;
        if (response.data.success) {
          this.product = response.data.product;
        }
      });
    },
    // 特定頁面加入購物車
    addToCart(id, qty = 1) {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      const cart = {
        product_id: id,
        qty,
      };
      this.isLoading = true;
      this.$http.post(url, { data: cart }).then((response) => {
        this.isLoading = false;
        this.$httpMessageState(response, '加入購物車');
        this.$router.push('/user/cart');
      });
    },
  },
  created() {
    this.id = this.$route.params.productId;
    this.getProduct();
  },
};
</script>

用戶端加入 Bootstrap Icon

  1. 製作購物車列表
  2. 提醒: api 細節,把購物車 api 打,會發現這邊的結構和先前的不太一樣,這裡是 data 裡面還有 data,主要是因為這個 data 裡面有兩個結構,一個是購物車的品項,加入非常多的項目都會存在這個 carts 裡面
  3. 在畫面的部分可以看到非常多的項目
    在 Console 查詢顯示的資料, carts、final_total、total
    carts、final_total, total 這兩個項目需要存下來,可以存在一個物件裡面,或者拆成兩個來另外做儲存,都是必要出現在畫面上的項目
  4. 在 UserCart.vue 檔案撰寫 getCart() 方法
    this.cart = response.data.data 所有的項目都存起來,這個 cart 就包含陣列的列表以及總金額
    在購物車列表就使用 v-if 判斷陣列有沒有存在,如果存在的情況下就使用 v-for 把陣列的內容完整的呈現
  5. 數量調整的功能尚未製作
  6. 使用 icon 的形式替換刪除中文字
    在 Bootstrap 文件找到 Icons
    安裝 Bootstrap Icons 套件
    把 Bootstrap Icons 載入到 main.js 檔案
    import ‘bootstrap-icons/font/bootstrap-icons.css’;
    把 icons 圖示加入
// views/UserCart.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="container">
    <div class="row mt-4">
      <div class="col-md-7">
        <table class="table align-middle">
          <thead>
            <tr>
              <th>圖片</th>
              <th>商品名稱</th>
              <th>價格</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in products" :key="item.id">
              <td style="width: 200px">
                <div style="height: 100px; background-size: cover; background-position: center"
                :style="{backgroundImage: `url(${item.imageUrl})`}"></div>
              </td>
              <td><a href="#" class="text-dark">{{ item.title }}</a></td>
              <td>
                <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>
              </td>
              <td>
                <div class="btn-group btn-group-sm">
                  <button type="button" class="btn btn-outline-secondary"
                  @click="getProduct(item.id)">
                    查看更多
                  </button>
                  <button type="button" class="btn btn-outline-danger"
                  @click="addCart(item.id)"
                  :disabled="this.status.loadingItem === item.id">
                    <div v-if="this.status.loadingItem === item.id"
                    class="spinner-grow text-danger spinner-grow-sm" role="status">
                      <span class="visually-hidden">Loading...</span>
                    </div>
                    加到購物車
                  </button>
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
      <!-- 購物車列表 -->
      <div class="col-md-5">
        <div class="sticky-top">
          <table class="table align-middle">
            <thead>
              <tr>
                <th></th>
                <th>品名</th>
                <th style="width: 110px">數量</th>
                <th>單價</th>
              </tr>
            </thead>
            <tbody>
            <template v-if="cart.carts">
              <tr v-for="item in cart.carts" :key="item.id">
                <td>
                  <button type="button" class="btn btn-outline-danger btn-sm"
                  :disabled="status.loadingItem === item.id"
                  @click="removeCartItem(item.id)">
                    <i class="bi bi-x"></i>
                  </button>
                </td>
                <td>
                  {{ item.product.title }}
                  <div class="text-success" v-if="item.coupon">
                    已套用優惠券
                  </div>
                </td>
                <td>
                  <div class="input-group input-group-sm">
                    <input type="number" class="form-control"
                          v-model.number="item.qty">
                    <div class="input-group-text">/ {{ item.product.unit }}</div>
                  </div>
                </td>
                <td class="text-end">
                  <small v-if="cart.final_total !== cart.total" class="text-success">折扣價:</small>
                  {{ $filters.currency(item.final_total) }}
                </td>
              </tr>
            </template>
            </tbody>
            <tfoot>
            <tr>
              <td colspan="3" class="text-end">總計</td>
              <td class="text-end">{{ $filters.currency(cart.total) }}</td>
            </tr>
            <tr v-if="cart.final_total !== cart.total">
              <td colspan="3" class="text-end text-success">折扣價</td>
              <td class="text-end text-success">{{ $filters.currency(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>
  </div>
</template>

<script>
export default {
  data() {
    return {
      products: [],
      product: {},
      // 讀取狀態
      status: {
        loadingItem: '', // 對應品項 id
      },
      cart: {},
      coupon_code: '',
    };
  },
  methods: {
    // 取得商品列表
    getProducts() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/products/all`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        this.products = response.data.products;
        console.log('products:', response);
        this.isLoading = false;
      });
    },
    // 進入單一商品頁面
    getProduct(id) {
      this.$router.push(`/user/product/${id}`);
    },
    // 加入購物車
    addCart(id) {
      // 測試 id 有沒有正確取出
      // console.log(id);
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.status.loadingItem = id;
      const cart = {
        product_id: id,
        qty: 1,
      };
      this.$http.post(url, { data: cart })
        .then((res) => {
          this.status.loadingItem = '';
          console.log(res);
        });
    },
    // 取得購物車列表
    getCart() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        console.log(response);
        this.cart = response.data.data;
        this.isLoading = false;
      });
    },
  },
  created() {
    this.getProducts();
    this.getCart();
  },
};
</script>
// src/main.js
import { createApp } from 'vue';
import axios from 'axios';
import VueAxios from 'vue-axios';
import Loading from 'vue3-loading-overlay';
import 'vue3-loading-overlay/dist/vue3-loading-overlay.css';
import 'bootstrap-icons/font/bootstrap-icons.css';

import App from './App.vue';
import router from './router';
import { currency, date } from './methods/filters';
import $httpMessageState from './methods/pushMessageState';

const app = createApp(App);
app.config.globalProperties.$filters = {
  currency,
  date,
};
// 此函式的用途是整合 AJAX 的錯誤事件,統一整理發送給予 Toast 處理
app.config.globalProperties.$httpMessageState = $httpMessageState;

app.use(VueAxios, axios);
app.use(router);
app.component('Loading', Loading);
app.mount('#app');

用戶端更新購物車數量品項

  1. 看資料的部分
    使用 Vue.js devtools 查看元件裡面的資料,購物車品項
    <RouterView> > <UserCart>
  2. 在 UserCart.vue 檔案
    可以看到 v-model.number=”item.qty”,item 指的是 product 品項,目前品項可以透過 input 來調整數量多寡,Vue.js devtools 的 qty 也會跟著做調整,目前 input 已經跟資料綁上
  3. 現在有個問題是調整數量的時候,是可以變為 0 或者負值的,要避免變成負值,送出才不會產生問題,在 input 必須加上最小值 min = 1,避免用戶將值調成低於 0 的狀態
  4. 看購物車的數量,當調整數量的時候,qty 數值是會跟著做改變,但是價格是不會跟著做改變,主要是因為價格是由後端所計算出,因此必須調整後端的購物車品項的數量,才會回傳新的價格
  5. 查看 API 文件更新購物車的部分,更新購物車 api 跟加入購物車很像,在更新購物車會加入購物車的 id,單一品項的 id,所傳遞的參數內容和加入購物車是一樣的
  6. 在 Vue.js devtools 購物車裡面有 id、產品的 id,這個 id 就是在購物車裡面單一品項的 id,另一個是產品的 id,因此必須把這兩個資訊傳到後端,才能計算新的金額
  7. 在程式碼數量的地方加上事件,使用 change 方法觸發更新購物車 @change=”updateCart()”,因為要傳送相關的資訊,購物車的品項 id 以及產品 id,在這個地方把 item 帶進來
  8. 在 JS 部分撰寫 updateCart() 方法
    加上 api 路徑、api 方法,會使用 put 方法
    組裝要傳遞的資訊,透過物件方式來進行傳遞
    使用 console 測試內容有沒有確實更新,在更新購物車項目之後,可以再重新取得完整購物車的內容
  9. 避免用戶重複點擊,加上讀取的效果
    this.status.loadingItem = item.id; 然後更新後清空
    在 HTML 部分加上 :disabled=”item.id === status.loadingItem”
// views/UserCart.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="container">
    <div class="row mt-4">
      <div class="col-md-7">
        <table class="table align-middle">
          <thead>
            <tr>
              <th>圖片</th>
              <th>商品名稱</th>
              <th>價格</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in products" :key="item.id">
              <td style="width: 200px">
                <div style="height: 100px; background-size: cover; background-position: center"
                :style="{backgroundImage: `url(${item.imageUrl})`}"></div>
              </td>
              <td><a href="#" class="text-dark">{{ item.title }}</a></td>
              <td>
                <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>
              </td>
              <td>
                <div class="btn-group btn-group-sm">
                  <button type="button" class="btn btn-outline-secondary"
                  @click="getProduct(item.id)">
                    查看更多
                  </button>
                  <button type="button" class="btn btn-outline-danger"
                  @click="addCart(item.id)"
                  :disabled="this.status.loadingItem === item.id">
                    <div v-if="this.status.loadingItem === item.id"
                    class="spinner-grow text-danger spinner-grow-sm" role="status">
                      <span class="visually-hidden">Loading...</span>
                    </div>
                    加到購物車
                  </button>
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
      <!-- 購物車列表 -->
      <div class="col-md-5">
        <div class="sticky-top">
          <table class="table align-middle">
            <thead>
              <tr>
                <th></th>
                <th>品名</th>
                <th style="width: 110px">數量</th>
                <th>單價</th>
              </tr>
            </thead>
            <tbody>
            <template v-if="cart.carts">
              <tr v-for="item in cart.carts" :key="item.id">
                <td>
                  <button type="button" class="btn btn-outline-danger btn-sm"
                  :disabled="status.loadingItem === item.id"
                  @click="removeCartItem(item.id)">
                    <i class="bi bi-x"></i>
                  </button>
                </td>
                <td>
                  {{ item.product.title }}
                  <div class="text-success" v-if="item.coupon">
                    已套用優惠券
                  </div>
                </td>
                <td>
                  <div class="input-group input-group-sm">
                    <input type="number" class="form-control"
                    min="1"
                    :disabled="item.id === status.loadingItem"
                    @change="updateCart(item)"
                    v-model.number="item.qty">
                    <div class="input-group-text">/ {{ item.product.unit }}</div>
                  </div>
                </td>
                <td class="text-end">
                  <small v-if="cart.final_total !== cart.total" class="text-success">折扣價:</small>
                  {{ $filters.currency(item.final_total) }}
                </td>
              </tr>
            </template>
            </tbody>
            <tfoot>
            <tr>
              <td colspan="3" class="text-end">總計</td>
              <td class="text-end">{{ $filters.currency(cart.total) }}</td>
            </tr>
            <tr v-if="cart.final_total !== cart.total">
              <td colspan="3" class="text-end text-success">折扣價</td>
              <td class="text-end text-success">{{ $filters.currency(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>
  </div>
</template>

<script>
export default {
  data() {
    return {
      products: [],
      product: {},
      // 讀取狀態
      status: {
        loadingItem: '', // 對應品項 id
      },
      cart: {},
      coupon_code: '',
    };
  },
  methods: {
    // 取得商品列表
    getProducts() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/products/all`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        this.products = response.data.products;
        console.log('products:', response);
        this.isLoading = false;
      });
    },
    // 進入單一商品頁面
    getProduct(id) {
      this.$router.push(`/user/product/${id}`);
    },
    // 加入購物車
    addCart(id) {
      // 測試 id 有沒有正確取出
      // console.log(id);
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.status.loadingItem = id;
      const cart = {
        product_id: id,
        qty: 1,
      };
      this.$http.post(url, { data: cart })
        .then((res) => {
          this.status.loadingItem = '';
          console.log(res);
        });
    },
    // 取得購物車列表
    getCart() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        console.log(response);
        this.cart = response.data.data;
        this.isLoading = false;
      });
    },
    // 更新購物車
    updateCart(item) {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart/${item.id}`;
      this.isLoading = true;
      // 讀取效果
      this.status.loadingItem = item.id;
      // 傳遞的資訊
      const cart = {
        product_id: item.product_id,
        qty: item.qty,
      };
      this.$http.put(url, { data: cart }).then((res) => {
        console.log(res);
        this.status.loadingItem = '';
        this.getCart();
      });
    },
  },
  created() {
    this.getProducts();
    this.getCart();
  },
};
</script>

用戶端套用優惠券

  1. 要使用優惠券的功能務必先把優惠券的後台完成,才可以套用優惠券的功能
    至少先新增一個優惠券
    優惠券名稱、到期日、是否啟用、折扣百分比會直接套用在購物車內的所有的品項
  2. 在購物車列表 console 看一下關於購物車的內容
    購物車品項有兩個價格 total、final_total,實際上套用優惠券會把 final_total 金額重新計算,再加總到最終的總金額,品項最終會套用到 final_total 裡面重新運算
  3. 以範例講解套用優惠券試試,看一下重新取得的列表內容,cart 所更新的購物車內容 final_total 的金額成功打折,另外把優惠券相關內容加入進來,品項最終會用打折後的價格方式進行計算
  4. 試著完成優惠券功能
    套用優惠券按鈕加上 addCouponCode 方法,在程式碼把 addCouponCode() 方法加入
    查看 API 文件套用優惠券 api,會發送 data 內容,data 裡面有一個 code 的屬性就是優惠券的名稱,優惠券名稱放在這個地方送出就可以了
    api 路徑、建立資料結構、this.$http.post 方法把 url 以及 coupon 內容帶進來,包在 data 的屬性下並且把上面的 coupon 帶進來,加上 then 回傳結果,然後重新取得購物車內容,把金額更新
  5. 加入購物車,重新整理後購物車列表就會新增一個品項
    套用優惠券,查看 console、折扣相關訊息呈現在畫面
  6. 製作 Coupon 也可以把讀取的效果還有相關的回應加上
  7. 試著製作刪除的功能
// views/UserCart.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="container">
    <div class="row mt-4">
      <div class="col-md-7">
        <table class="table align-middle">
          <thead>
            <tr>
              <th>圖片</th>
              <th>商品名稱</th>
              <th>價格</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in products" :key="item.id">
              <td style="width: 200px">
                <div style="height: 100px; background-size: cover; background-position: center"
                :style="{backgroundImage: `url(${item.imageUrl})`}"></div>
              </td>
              <td><a href="#" class="text-dark">{{ item.title }}</a></td>
              <td>
                <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>
              </td>
              <td>
                <div class="btn-group btn-group-sm">
                  <button type="button" class="btn btn-outline-secondary"
                  @click="getProduct(item.id)">
                    查看更多
                  </button>
                  <button type="button" class="btn btn-outline-danger"
                  @click="addCart(item.id)"
                  :disabled="this.status.loadingItem === item.id">
                    <div v-if="this.status.loadingItem === item.id"
                    class="spinner-grow text-danger spinner-grow-sm" role="status">
                      <span class="visually-hidden">Loading...</span>
                    </div>
                    加到購物車
                  </button>
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
      <!-- 購物車列表 -->
      <div class="col-md-5">
        <div class="sticky-top">
          <table class="table align-middle">
            <thead>
              <tr>
                <th></th>
                <th>品名</th>
                <th style="width: 110px">數量</th>
                <th>單價</th>
              </tr>
            </thead>
            <tbody>
            <template v-if="cart.carts">
              <tr v-for="item in cart.carts" :key="item.id">
                <td>
                  <button type="button" class="btn btn-outline-danger btn-sm"
                  :disabled="status.loadingItem === item.id"
                  @click="removeCartItem(item.id)">
                    <i class="bi bi-x"></i>
                  </button>
                </td>
                <td>
                  {{ item.product.title }}
                  <div class="text-success" v-if="item.coupon">
                    已套用優惠券
                  </div>
                </td>
                <td>
                  <div class="input-group input-group-sm">
                    <input type="number" class="form-control"
                    min="1"
                    :disabled="item.id === status.loadingItem"
                    @change="updateCart(item)"
                    v-model.number="item.qty">
                    <div class="input-group-text">/ {{ item.product.unit }}</div>
                  </div>
                </td>
                <td class="text-end">
                  <small v-if="cart.final_total !== cart.total" class="text-success">折扣價:</small>
                  {{ $filters.currency(item.final_total) }}
                </td>
              </tr>
            </template>
            </tbody>
            <tfoot>
            <tr>
              <td colspan="3" class="text-end">總計</td>
              <td class="text-end">{{ $filters.currency(cart.total) }}</td>
            </tr>
            <tr v-if="cart.final_total !== cart.total">
              <td colspan="3" class="text-end text-success">折扣價</td>
              <td class="text-end text-success">{{ $filters.currency(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>
  </div>
</template>

<script>
export default {
  data() {
    return {
      products: [],
      product: {},
      // 讀取狀態
      status: {
        loadingItem: '', // 對應品項 id
      },
      cart: {},
      coupon_code: '',
    };
  },
  methods: {
    // 取得商品列表
    getProducts() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/products/all`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        this.products = response.data.products;
        console.log('products:', response);
        this.isLoading = false;
      });
    },
    // 進入單一商品頁面
    getProduct(id) {
      this.$router.push(`/user/product/${id}`);
    },
    // 加入購物車
    addCart(id) {
      // 測試 id 有沒有正確取出
      // console.log(id);
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.status.loadingItem = id;
      const cart = {
        product_id: id,
        qty: 1,
      };
      this.$http.post(url, { data: cart })
        .then((res) => {
          this.status.loadingItem = '';
          console.log(res);
          // 重新取得購物車列表
          this.getCart();
        });
    },
    // 取得購物車列表
    getCart() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        console.log(response);
        this.cart = response.data.data;
        this.isLoading = false;
      });
    },
    // 刪除購物車品項
    removeCartItem(id) {
      this.status.loadingItem = id;
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart/${id}`;
      this.isLoading = true;
      this.$http.delete(url).then((response) => {
        this.$httpMessageState(response, '移除購物車品項');
        this.status.loadingItem = '';
        this.getCart();
        this.isLoading = false;
      });
    },
    // 更新購物車
    updateCart(item) {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart/${item.id}`;
      this.isLoading = true;
      // 讀取效果
      this.status.loadingItem = item.id;
      // 傳遞的資訊
      const cart = {
        product_id: item.product_id,
        qty: item.qty,
      };
      this.$http.put(url, { data: cart }).then((res) => {
        console.log(res);
        this.status.loadingItem = '';
        this.getCart();
      });
    },
    // 套用優惠碼
    addCouponCode() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/coupon`;
      // 建立資料結構
      const coupon = {
        code: this.coupon_code,
      };
      // 讀取效果
      this.isLoading = true;
      this.$http.post(url, { data: coupon })
        .then((res) => {
          console.log(res);
          this.getCart();
          this.isLoading = false;
        });
    },
  },
  created() {
    this.getProducts();
    this.getCart();
  },
};
</script>

用戶端建立訂單

  1. 購物車已經有部分的品項
  2. 製作結帳的部分
  3. 建立結帳表單結構
  4. 安裝、套用 VeeValidate
    npm i vee-validate @vee-validate/rules @vee-validate/i18n –save
    注意: import * as AllRules from ‘@vee-validate/rules’;
  5. 查看購物車 api 的部分,使用結帳頁面的 api
    結帳頁面 [參數] – data、message,建立表單的時候結構要注意
  6. 在 UserCart.vue 檔案建立結構是使用 form 物件包著 user 以及 message 區塊
  7. 建立訂單在 <Form> 標籤加上 @submit 行為觸發強制驗證,使用 createOrder 方法
    在程式碼 methods 方法加上 createOrder 方法,把 createOrder 加到 message 裡面,把 @submit 加在 <Form> 標籤上的好處是當送出表單的時候會強制先進行驗證,除非內容都符合資格,否則表但不會送出
    在 createOrder 可以先把 api 的網址建立起來
    建立表單的結構
    使用 this.$http.post 方法,帶上 url 路徑、使用物件把 data 屬性帶上 order 的方式把表單內容送出
    使用 console 查看回應有沒有正確
  8. 送出訂單要先確定購物車裡面是有品項,再把下面的內容填寫,送出表單查看回應的結果
// src/main.js
import { createApp } from 'vue';
import axios from 'axios';
import VueAxios from 'vue-axios';
import Loading from 'vue3-loading-overlay';
import 'vue3-loading-overlay/dist/vue3-loading-overlay.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import {
  Form, Field, ErrorMessage, defineRule, configure,
} from 'vee-validate';
// import AllRules from '@vee-validate/rules'; // 會產生錯誤
import * as AllRules from '@vee-validate/rules';
import { localize, setLocale } from '@vee-validate/i18n';
import zhTW from '@vee-validate/i18n/dist/locale/zh_TW.json';

import App from './App.vue';
import router from './router';
import { currency, date } from './methods/filters';
import $httpMessageState from './methods/pushMessageState';

const app = createApp(App);
app.config.globalProperties.$filters = {
  currency,
  date,
};

Object.keys(AllRules).forEach((rule) => {
  defineRule(rule, AllRules[rule]);
});

configure({
  generateMessage: localize({ zh_TW: zhTW }), // 載入繁體中文語系
  validateOnInput: true, // 當輸入任何內容直接進行驗證
});
// 設定預設語系
setLocale('zh_TW');

// 此函式的用途是整合 AJAX 的錯誤事件,統一整理發送給予 Toast 處理
app.config.globalProperties.$httpMessageState = $httpMessageState;

app.use(VueAxios, axios);
app.use(router);
app.component('Loading', Loading);
app.component('Form', Form);
app.component('Field', Field);
app.component('ErrorMessage', ErrorMessage);
app.mount('#app');
// views/UserCart.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="container">
    <div class="row mt-4">
      <div class="col-md-7">
        <table class="table align-middle">
          <thead>
            <tr>
              <th>圖片</th>
              <th>商品名稱</th>
              <th>價格</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="item in products" :key="item.id">
              <td style="width: 200px">
                <div style="height: 100px; background-size: cover; background-position: center"
                :style="{backgroundImage: `url(${item.imageUrl})`}"></div>
              </td>
              <td><a href="#" class="text-dark">{{ item.title }}</a></td>
              <td>
                <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>
              </td>
              <td>
                <div class="btn-group btn-group-sm">
                  <button type="button" class="btn btn-outline-secondary"
                  @click="getProduct(item.id)">
                    查看更多
                  </button>
                  <button type="button" class="btn btn-outline-danger"
                  @click="addCart(item.id)"
                  :disabled="this.status.loadingItem === item.id">
                    <div v-if="this.status.loadingItem === item.id"
                    class="spinner-grow text-danger spinner-grow-sm" role="status">
                      <span class="visually-hidden">Loading...</span>
                    </div>
                    加到購物車
                  </button>
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
      <!-- 購物車列表 -->
      <div class="col-md-5">
        <div class="sticky-top">
          <table class="table align-middle">
            <thead>
              <tr>
                <th></th>
                <th>品名</th>
                <th style="width: 110px">數量</th>
                <th>單價</th>
              </tr>
            </thead>
            <tbody>
            <template v-if="cart.carts">
              <tr v-for="item in cart.carts" :key="item.id">
                <td>
                  <button type="button" class="btn btn-outline-danger btn-sm"
                  :disabled="status.loadingItem === item.id"
                  @click="removeCartItem(item.id)">
                    <i class="bi bi-x"></i>
                  </button>
                </td>
                <td>
                  {{ item.product.title }}
                  <div class="text-success" v-if="item.coupon">
                    已套用優惠券
                  </div>
                </td>
                <td>
                  <div class="input-group input-group-sm">
                    <input type="number" class="form-control"
                    min="1"
                    :disabled="item.id === status.loadingItem"
                    @change="updateCart(item)"
                    v-model.number="item.qty">
                    <div class="input-group-text">/ {{ item.product.unit }}</div>
                  </div>
                </td>
                <td class="text-end">
                  <small v-if="cart.final_total !== cart.total" class="text-success">折扣價:</small>
                  {{ $filters.currency(item.final_total) }}
                </td>
              </tr>
            </template>
            </tbody>
            <tfoot>
            <tr>
              <td colspan="3" class="text-end">總計</td>
              <td class="text-end">{{ $filters.currency(cart.total) }}</td>
            </tr>
            <tr v-if="cart.final_total !== cart.total">
              <td colspan="3" class="text-end text-success">折扣價</td>
              <td class="text-end text-success">{{ $filters.currency(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>
    <!-- 用戶端訂單 -->
    <div class="my-5 row justify-content-center">
      <Form class="col-md-6" v-slot="{ errors }"
            @submit="createOrder">
        <div class="mb-3">
          <label for="email" class="form-label">Email</label>
          <Field id="email" name="email" type="email" class="form-control"
          :class="{ 'is-invalid': errors['email'] }"
          placeholder="請輸入 Email" rules="email|required"
          v-model="form.user.email"></Field>
          <ErrorMessage name="email" class="invalid-feedback"></ErrorMessage>
        </div>

        <div class="mb-3">
          <label for="name" class="form-label">收件人姓名</label>
          <Field id="name" name="姓名" type="text" class="form-control"
          :class="{ 'is-invalid': errors['姓名'] }"
          placeholder="請輸入姓名" rules="required"
          v-model="form.user.name"></Field>
          <ErrorMessage name="姓名" class="invalid-feedback"></ErrorMessage>
        </div>

        <div class="mb-3">
          <label for="tel" class="form-label">收件人電話</label>
          <Field id="tel" name="電話" type="tel" class="form-control"
          :class="{ 'is-invalid': errors['電話'] }"
          placeholder="請輸入電話" rules="required"
          v-model="form.user.tel"></Field>
          <ErrorMessage name="電話" class="invalid-feedback"></ErrorMessage>
        </div>

        <div class="mb-3">
          <label for="address" class="form-label">收件人地址</label>
          <Field id="address" name="地址" type="text" class="form-control"
          :class="{ 'is-invalid': errors['地址'] }"
          placeholder="請輸入地址" rules="required"
          v-model="form.user.address"></Field>
          <ErrorMessage name="地址" class="invalid-feedback"></ErrorMessage>
        </div>

        <div class="mb-3">
          <label for="message" class="form-label">留言</label>
          <textarea name="" id="message" class="form-control" cols="30" rows="10"
                    v-model="form.message"></textarea>
        </div>
        <div class="text-end">
          <button class="btn btn-danger">送出訂單</button>
        </div>
      </Form>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      products: [],
      product: {},
      // 讀取狀態
      status: {
        loadingItem: '', // 對應品項 id
      },
      cart: {},
      coupon_code: '',
      // 用戶端訂單
      form: {
        user: {
          name: '',
          email: '',
          tel: '',
          address: '',
        },
        message: '',
      },
    };
  },
  methods: {
    // 取得商品列表
    getProducts() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/products/all`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        this.products = response.data.products;
        console.log('products:', response);
        this.isLoading = false;
      });
    },
    // 進入單一商品頁面
    getProduct(id) {
      this.$router.push(`/user/product/${id}`);
    },
    // 加入購物車
    addCart(id) {
      // 測試 id 有沒有正確取出
      // console.log(id);
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.status.loadingItem = id;
      const cart = {
        product_id: id,
        qty: 1,
      };
      this.$http.post(url, { data: cart })
        .then((res) => {
          this.status.loadingItem = '';
          console.log(res);
          // 重新取得購物車列表
          this.getCart();
        });
    },
    // 取得購物車列表
    getCart() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart`;
      this.isLoading = true;
      this.$http.get(url).then((response) => {
        console.log(response);
        this.cart = response.data.data;
        this.isLoading = false;
      });
    },
    // 刪除購物車品項
    removeCartItem(id) {
      this.status.loadingItem = id;
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart/${id}`;
      this.isLoading = true;
      this.$http.delete(url).then((response) => {
        this.$httpMessageState(response, '移除購物車品項');
        this.status.loadingItem = '';
        this.getCart();
        this.isLoading = false;
      });
    },
    // 更新購物車
    updateCart(item) {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/cart/${item.id}`;
      this.isLoading = true;
      // 讀取效果
      this.status.loadingItem = item.id;
      // 傳遞的資訊
      const cart = {
        product_id: item.product_id,
        qty: item.qty,
      };
      this.$http.put(url, { data: cart }).then((res) => {
        console.log(res);
        this.status.loadingItem = '';
        this.getCart();
      });
    },
    // 套用優惠碼
    addCouponCode() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/coupon`;
      // 建立資料結構
      const coupon = {
        code: this.coupon_code,
      };
      // 讀取效果
      this.isLoading = true;
      this.$http.post(url, { data: coupon })
        .then((res) => {
          console.log(res);
          this.getCart();
          this.isLoading = false;
        });
    },
    // 建立訂單
    createOrder() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/order`;
      const order = this.form;
      this.$http.post(url, { data: order })
        .then((res) => {
          console.log(res);
        });
    },
  },
  created() {
    this.getProducts();
    this.getCart();
  },
};
</script>

用戶端結帳流程

  1. 建立新的一筆訂單,並填好用戶表端資料,預期在送出訂單之後會產一個 orderId,複製 orderId 的值等下使用,送出訂單之後預期會轉只到另外一個頁面,checkout 路由下的頁面,讓用戶確認產品還有表單內容是否正確,再進行結帳的流程,現在的流程是必須先把 checkout 加上再讓用戶進行結帳
  2. 新增 UserCheckout.vue 檔案撰寫程式碼
    完成 checkout 頁面,上方是產品的內容,在購物車裡面的品項,下方是用戶自己所填的細節,會透過 orderId 把資訊取回來
  3. 著重在 JS 的部分
  4. 在 index.js 檔案加上子路由 checkout/:orderId,orderId 作用是取回原本同一筆訂單使用的
  5. 直接使用網址路徑進入用戶端結帳頁面
    查看 API 文件取得某一筆訂單,透過 orderId 取得相同一筆訂單內容
    必須先把路由上面的 id 取出來,在 data() 資料定義 orderId,在 created() 生命週期撰寫 this.orderId = this.$route.params.orderId 把 orderId 取出
    使用 console 查看,orderId 是從網址列所取得
    加上 this.getOrder() 方法,會觸發 methods 裡面的 getOrder(),會透過 getOrder 取得需要的相關內容
  6. 在 getOrder() 方法撰寫取得某一筆訂單
    api 路徑取得特定訂單的內容、使用 this.$http.get 方法取得這筆訂單內容
    使用 console 是否有正確取得
    將相關的內容呈現在畫面上
  7. 製作確認付款去的行為
    先看 order 包含什麼資訊,新增品項的時間、id、總金額、is_paid 屬性,is_paid 是用戶是否已經付款,is_paid只能由後端進行調整
    用戶觸發結帳付款 api,觸發之後就會把 is_paid 的 false 改為 true
    結帳付款 api 主要的重點在 orderId,會透過 orderId 搭配前一個 pay api 將這筆訂單的 is_paid 改為 true
  8. 針對這筆訂單進行付款
    在 <form> 表單加上 payOrder 方法,當用戶按下按鈕或是送出表單的時候,就會觸發 payOrder 行為
    撰寫 payOrder() 方法
    url 路徑、使用 this.$http.post 方法將這筆訂單進行付款,發出請求後會使用 then 接收回傳
    將這筆訂單完成之後就不能再次使用,重整再重新取得訂單的項目,再重新觸發一次 getOrder 行為
  9. 按下確認付款去之後,會觸發一個行為,會出現付款已經完成,在畫面上付款狀態會改成付款完成,確認付款去按鈕就會隱藏
  10. 查看 console 關於 this.order 這筆訂單的資訊 is_paid 會改為 true 的狀態,並且增加 paid_date 屬性,紀錄用戶在哪一個時間點進行付款
// views/UserCheckout.vue
<template>
  <Loading :active="isLoading"></Loading>
  <div class="my-5 row justify-content-center">
    <form class="col-md-6" @submit.prevent="payOrder">
      <table class="table align-middle">
        <thead>
        <th>品名</th>
        <th>數量</th>
        <th>單價</th>
        </thead>
        <tbody>
        <tr v-for="item in order.products" :key="item.id">
          <td>{{ item.product.title }}</td>
          <td>{{ item.qty }}/{{ item.product.unit }}</td>
          <td class="text-end">{{ item.final_total }}</td>
        </tr>
        </tbody>
        <tfoot>
        <tr>
          <td colspan="2" class="text-end">總計</td>
          <td class="text-end">{{ order.total }}</td>
        </tr>
        </tfoot>
      </table>

      <table class="table">
        <tbody>
        <tr>
          <th width="100">Email</th>
          <td>{{ order.user.email }}</td>
        </tr>
        <tr>
          <th>姓名</th>
          <td>{{ order.user.name }}</td>
        </tr>
        <tr>
          <th>收件人電話</th>
          <td>{{ order.user.tel }}</td>
        </tr>
        <tr>
          <th>收件人地址</th>
          <td>{{ order.user.address }}</td>
        </tr>
        <tr>
          <th>付款狀態</th>
          <td>
            <span v-if="!order.is_paid">尚未付款</span>
            <span v-else class="text-success">付款完成</span>
          </td>
        </tr>
        </tbody>
      </table>
      <div class="text-end" v-if="order.is_paid === false">
        <button class="btn btn-danger">確認付款去</button>
      </div>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      order: {
        user: {},
      },
      orderId: '',
      isLoading: false,
    };
  },
  methods: {
    // 取得某一筆訂單
    getOrder() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/order/${this.orderId}`;
      this.$http.get(url)
        .then((res) => {
          if (res.data.success) {
            this.order = res.data.order;
            console.log(this.order);
          }
        });
    },
    // 確認付款
    payOrder() {
      const url = `${process.env.VUE_APP_API}api/${process.env.VUE_APP_PATH}/pay/${this.orderId}`;
      this.$http.post(url)
        .then((res) => {
          console.log(res);
          if (res.data.success) {
            this.getOrder();
          }
        });
    },
  },
  created() {
    this.orderId = this.$route.params.orderId;
    console.log(this.orderId);
    this.getOrder();
  },
};
</script>
// src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import Home from '../views/Home.vue';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),
  },
  {
    path: '/login',
    component: () => import('../views/Login.vue'),
  },
  {
    path: '/dashboard',
    component: () => import('../views/Dashboard.vue'),
    // 巢狀路由 - 產品列表
    children: [
      {
        path: 'products',
        component: () => import('../views/Products.vue'),
      },
      {
        path: 'orders',
        component: () => import('../views/Orders.vue'),
      },
      {
        path: 'coupons',
        component: () => import('../views/Coupons.vue'),
      },
    ],
  },
  {
    path: '/user',
    component: () => import('../views/Userboard.vue'),
    children: [
      {
        path: 'cart',
        component: () => import('../views/UserCart.vue'),
      },
      {
        path: 'product/:productId',
        component: () => import('@/views/UserProduct.vue'),
      },
      {
        path: 'checkout/:orderId',
        component: () => import('../views/UserCheckout.vue'),
      },
    ],
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
  linkActiveClass: 'active',
});

export default router;

最終作業說明

最終作業繳交說明

一般練習
  • 僅檢視原始碼
  • 僅需完成課程基本條件
  • 僅需要完成桌面版
求職作品
  • 檢視畫面設計及原始碼
  • 除了課程 API 需增加自己創意在內
  • 行動版、桌面版皆需製作
  • 已正式上線為目標
  • 退件率高
  • 請標示作為求職作品使用

作品製作建議

先決定主題
  • 搜集素材
  • 確認主題製作可行性
  • 定義產品資訊
  • 構思技術小巧思 (我的最愛、抽獎、地圖功能、倒數優惠…)
  • 開始執行

素材資源

你的用心,會讓作品更加耀眼

盡可能避免的錯誤
  • 缺少文案、作品不完整
  • 沒有對齊、畫面跑版
  • 使用者體驗的問題: 例如購物車為空時,避免讓用戶進入下一步
  • 行動版無法運作

不知如何下手嗎

萬丈高樓平地起,規劃優於一切
  • 先參考學長姐的作品
  • 從搜集資料開始,規劃網站的文案、產品、各種小巧思
  • 先製作版型,將流程一一的順好
  • 將功能一一套上版型
  • 最後修正,提交作業

最終作業提交規則文件

最終作業提交 – 程式勇者村