最終挑戰
最終作業課程介紹
課程目標
透過課程專屬 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 文件及相關資源
- 課程需要先註冊屬於個人的 API 路徑,註冊方法在下一小節會介紹,註冊網址與 API 站點連結
- API 文件
- 課程中後期,不會所有步驟都一一說明,所以課程中有提供每個階段的 commit,讓大家可以看到每個章節老師修改了哪些部分:所有課程進度 Commit (對應課程章節)
- 課程中也會提供許多 HTML 片段模板,減少重複繁瑣的行為,如提到會提供模板的部分,連結
- 雖然課程中 ESLint 選擇為 Airbnb 格式:
- 體驗簡單一點的開發規則可選擇 Standard
- 對 ES6 及錯誤排除有一定掌握者可選擇 Airbnb
關於 ESLint 搭配 VSCode 的自動排版可參考(注意,並非所有錯誤都可自動排除):連結
使用時 ESLint 時:
- 可多利用文字編輯器的提示來除錯(除錯也是開發者必學的技能之一)
- 盡可能避免關閉 ESLint 的提示
申請課程 API
流程說明:
- 申請一個專屬的課程練習帳號
- 登入帳號,並申請一個 API 路徑
- 測試 API 是否可以運作,並且開始實作
複習 Vue Cli 建立環境
- 使用 Vue Cli 建立環境 vue create vue3_dashboard_record
- Please pick a preset: Manually select features
- Check the features needed for your project: Choose Vue version, Babel, Router, CSS Pre-processors, Linter
- Choose a version of Vue.js that you want to start the project with 3.x (Preview)
- Use history mode for router? (Requires proper server setup for index fallback in production) No
- Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with node-sass)
- Pick a linter / formatter config: Airbnb
- Pick additional lint features: Lint on save
- Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
- Save this as a preset for future projects? (y/N) N
API 路徑加到環境變數
- 在根目錄新增 .env 檔案
API Server 路徑: VUE_APP_API
API 個人路徑: VUE_APP_PATH - 開啟 Home.vue 檔案調整程式碼內容
使用生命週期讀出環境變數 - 重新執行 npm run serve
- 開啟 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 並調整版型
- 安裝 Bootstrap 套件 – npm install bootstrap
- 引用 Bootstrap 到專案裡面
Bootstrap 文件 > Customize > Sass > Importing
複製 @import “../node_modules/bootstrap/scss/bootstrap”; - 開啟 App.vue 檔案貼上程式碼後改寫
@import “~bootstrap/scss/bootstrap”; - 重新運行 npm run serve
- 在 Bootstrap 文件 > 元件 > 按鈕
複製按鈕程式碼貼到 App.vue 檔案測試是否有正確載入 Bootstrap 套件 - 客製化 Bootstrap 樣式
在 assets 資料夾新增 all.scss 檔案
在 assets 資料夾新增 helpers 資料夾
在 assets/helpers 新增 _variables.scss 檔案 (_在scss不會被編譯出來) - 找到 node_modules/bootstrap/scss/_variables.scss 打開
複製 _variables.scss 所有程式碼直接貼到 assets/helpers/_variables.scss 檔案 - 客製化變數
在 Bootstrap 文件 > Customize > Sass > Importing
複製文件需要的程式碼貼到 assets/all.scss 並改寫
匯入所有的 Bootstrap - 在 App.vue 檔案調整成自定義 Sass 匯入路徑
- 在 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/
- 查看登入及驗證 API 文件
[API]、[方法]、[參數]、[成功回應]、[失敗回應] - 安裝 vue-axios 套件
npm install –save axios vue-axios - 匯入 vue-axios 套件
Vue2, Vue3 載入方式有所不同
複製程式碼貼到 main.js 檔案 - 調整 main.js 檔案結構
- 重新運行 npm run serve
- 新增 views/Login.vue 檔案
製作簡單頁面確保現在頁面已經建立成功
建立檔案再調整路由 - 查看 login 畫面有無正確運作
http://localhost:8080/#/login - 清除 App.vue 檔案多餘的程式碼片段
- 在 Login.vue 加入登入的版型
使用課程部分模板調整使用 - 撰寫 Login.vue <script> JS 的部分
查看登入及驗證文件登入參數的物件格式、撰寫在 data 資料
使用 v-model 雙向綁定表單資料 - 測試是否能正常運作
輸入表單資料送出、查看 Vue.js devtools - 串接 API
在 Login.vue 撰寫 methods 方法
把事件加到 <form> 標籤,使用 @submit 事件
可加上 prevent 避免觸發 html 預設事件 - 串接 API 必須把路徑組起來
環境變數站點位置+登入的實際 API
${process.env.VUE_APP_API}/admin/signin - 送出 api
使用 this.$http.post 方法 - 試著登入測試是否有回傳資料
// 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
- 查看瀏覽器 Cookie 儲存位置
- 查看 MDN 文件 – Cookie 文件連結
- 撰寫 document.cookie 程式碼
- 登入後查看瀏覽器 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}`;
確認是否維持登入狀態
- 查看登入及驗證文件
檢查用戶是否仍持續登入
驗證方法 – 將 Token 加入 Headers - Token
把 Token 從 Cookie 取出
把 Token 加到 Headers - 查看 MDN 文件 – Cookie 文件連結
複製 myCookie 程式碼並做改寫 - 查看 axios 文件 – Global axios defaults
- 實作把 Cookie 取出來、以及把 Token 發送出去
- 新增 views/Dashboard.vue 檔案
在 router/index.js 檔案把 Dashboard 加到路由表
http://localhost:8080/#/dashboard
查看是否有正確運作 - 在 Dashboard.vue 檔案撰寫程式碼
專注在 JS 撰寫
取出 token
把token 夾帶到 headers 裡面
複製 Global axios defaults 程式碼 - 試著觸發剛剛的 API
複製 Login.vue signIn() 裡面的程式碼
把多餘的程式碼清掉
檢查用戶是否仍然持續登入,把 API 路徑改成 api/user/check - 查看 console 回傳的 data 是否是成功
- 清除 Cookie 查看是否仍正確登入
- 在 Login.vue 檔案補上登入判斷,登入成功後會轉址到 Dashboard 頁面
- 在 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
- 新增 Products.vue 檔案 – 產品頁面
- 在 Dashboard.vue 檔案新增 Navbar
在 Bootstrap 文件搜尋 Navbar 尋找相對簡單的程式碼複製使用 - 在 App.vue 檔案把預設的連結清除
複製 <router-view> 貼到 Dashboard.vue - 打開 router/index.js 路由表
在 dashboard 路由新增 products 子路由
http://localhost:8080/#/dashboard/products - 在 Login.vue 檔案調整程式碼
登入成功後的轉址改為 /dashboard/products - 在 Dashboard.vue 檔案把 Navbar 程式碼拆分到元件
在 src/components 新增 Navbar.vue 檔案
把 Dashboard.vue 檔案 Navbar 程式碼剪下貼到 Navbar.vue 檔案 - 在 Dashboard.vue 匯入 Navbar.vue
import Navbar from ‘../components/Navbar.vue’;
使用 components 區域註冊 Navbar
再把 <Navbar> 標籤加到畫面 - 在 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;
加入產品列表
- 主要撰寫 Products.vue 檔案
稍微調整 Dashboard.vue 檔案 - 調整 Dashboard.vue 檔案程式碼
<router-view> 標籤外層加上 .container-fluid - 在 Products.vue 檔案
用列表的形式完成,複製課程部分模板程式碼貼上 - 在 Products.vue 撰寫 JS 部分
定義 data() 回傳資料,products 資料是陣列、分頁資訊是物件 - 在 Products.vue 撰寫取得遠端資料的方法
定義 methods 物件,getProducts() 方法
getProducts() 取得產品列表是多數的產品資訊 - 查看 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 - 使用生命週期 created() 觸發 getProducts() 方法
查看 Console 是否有正確取得遠端資料
可以儲存產品資訊和分頁資訊,調整 getProducts() 方法程式碼 - 使用 Vue 開發者工具檢視
- 把產品資訊渲染到畫面上
// 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
- 製作新增產品的彈跳視窗
使用事件的方式呼叫元件 - 查看 Bootstrap 文件
元件 > 互動視窗 Modal > 完整範例 Live Demo,是透過 HTML 方式呼叫 Modal
因為使用 Vue.js,所以盡可能使用 JS 方式呼叫 Modal
元件 > 互動視窗 Modal > 用法 > 傳遞選項
使用 new bootstrap modal 方法把 modal 實體化 - 在 components 資料夾新增 ProductModal.vue 檔案
- 在 Bootstrap 複製完整範例 Live Demo Modal 部分的程式碼貼到 ProductModeal.vue 檔案
- 在 ProductModal.vue 檔案加入 JS
著重在 JS 的部分
在元件新增方法讓外部元件呼叫 - 調整 ProductModal.vue 檔案 HTML 的部分
加上 ref 屬性,透過 ref 方式直接存取 DOM 元素 - 撰寫 ProductModal.vue 檔案 JS 的部分
- 參考 Bootstrap 文件 元件 > 互動視窗 Modal > 用法 > 透過 JavaScript
複製程式碼貼到 mounted 裡面 - 在調用之前必須把 Bootstrap 的 Modal 方法載出來
在 node_modules/bootstrap/js/dist/modal.js 檔案,把 modal.js 檔案載進來
會使用 import 方法載入 Modal
調整 mounted 裡面程式碼,改成 new Modal - 透過 refs方式把 DOM 元素指向外層的 ref modal
繼續調整 mounted 裡面程式碼
this.$refs.modal - 前面的變數再指回 data 裡面定義的變數
改成 this.modal - 在 mounted 加上 this.modal.show();
- 在 Products.vue 檔案
直接到 JS 部分直接使用 import 方式把 ProductModal 方法載進來
定義 components 進行 ProductModal 區域註冊
把 Productmodal 加到畫面上面 - 把剩餘的一些方法補上
showModal 方法、hideModal 方法 - 在 Products.vue 檔案
找到 <ProductModal> 標籤定義名稱 ref=”productModal”
使用 ref 方式直接呼叫 Modal 方法 - 在 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>
透過彈出視窗新增品項
- 新增產品在互動邏輯是相對複雜很多
- 查看管理控制台 [需驗證] API 文件 – 商品建立
- 新增產品是如何運作
預期點擊右上方新增產品會跳出一個彈出視窗,輸入內容後按下確認把資料新增 - 這個章節最複雜的是有一個列表的元件,彈出視窗會有另外一個元件,這兩個元件之間的溝通是這個章節最複雜的地方
- 透過繪圖的形式了解元件之間是怎麼溝通
- 在 ProductModal.vue、Products.vue 檔案
在 ProductModal.vue 檔案建構表單所需要的 HTML 以及 v-model 雙向綁定 - 提醒: 上傳圖片的行為
- 在 ProductModal.vue 檔案 data() 裡面新增 tempProduct 物件進行外層的資料傳送的接收
- 在外層 Products.vue 檔案調整程式碼
新增 updateProduct() 方法的事件
調整獨立 openModal() 事件 - openModal() 方法加到增加一個產品按鈕
- tempProduct 資料傳送到內層
在內層 ProductModal.vue 建立 props 使用物件格式建立,名稱為 product
在外層 Products.vue data() 新增 tempProduct - 內層所接收的 props
傳進來時使用 product 進行接收,product 是一個物件,預期傳進來的型別是物件 object,預設的情況下如果外層沒有正確的傳遞給予一個預設值 default() 直接回傳一個空的物件 - props 原則是前內後外
在 Products.vue 檔案 <ProductModal> 標籤撰寫程式碼
:product=”tempProduct” - 運作流程,外層的 tempProduct 透過 props 傳送進來,內層會使用 product 進行接收
- 監聽 product 內容有沒有更動,使用 watch 監聽,watch 是一個物件,監聽外層傳進來的 props
watch 的目的把傳進來的資料寫到 tempProduct 裡面
因為單向數據流不可以直接修改外層的資料
this.tempProduct = this.product; - 打開開發人員工具 > Vue 開發者工具
可以找到 <ProductModal>
按下增加一個產品,輸入表單內容就可以看到 tempProduct 有增加一些內容 - 使用 emit 事件把 tempProduct 資料傳送到遠端
在內層 ProductModal.vue 檔案 <button> 藉由按鈕觸發 emit 事件 @click=”$emit(‘update-product’)”
觸發 emit 事件的同時把 tempProduct 向外傳遞 - 在外層 Products.vue 預期會使用 updateProduct 進行接收,<ProductModal> 標籤使用 @update-product=”updateProduct”
使用前內後外的概念,前面是內層的元件、後面是外層所接收的函式 - 運作流程會使用 $emit 觸發外層的事件,觸發事件的名稱 updateProduct,接下來觸發 updateProduct() 函式,觸發的同時會把 tempProduct 資料內容透過參數的方式傳到 item 裡面
- 測試 tempProduct 參數有沒有正確傳過來
測試新增品項功能是否能正常運作 - 必須把開啟的 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>
產品資料更新
- 在 Products.vue 檔案的 tempProduct不是只為了新增,還為了編輯做使用
- 在範例點擊其中一個品項的編輯,會把這一個產品的品項新增到 tempProduct,送出的不是空物件,而是所點擊的這個產品的品項,因此這個 tempProduct 主要的用途是為了編輯做使用
- 查看管理控制台 [需驗證] API 文件 > 修改產品
查看 [API]、[方法] 與商品建立的差異
調整程式碼撰寫修改產品的功能 - 主要調整產品列表的部分
在 Products.vue 檔案
會透過屬性來判斷目前是否是新增的狀態,在 data() 新增一個 isNew 的狀態,目前是 false - 在 openModal 新增兩個參數,一個是不是新的、另一個是編輯的話,把編輯的品項加進來
使用 console 查看
在 HTML 的 openModal 加入參數 true
@click=”openModal(true)
在品項的編輯按鈕加上 @click=”openModal(false, item),false、以及當前的品項
當前的品項是把 v-for 裡面的 item 帶到編輯裡面 - 在 openModal(isNew, item) 加上流程判斷、調整程式碼
測試編輯功能是否能正確運作 - 調整更新的部分
查看管理控制台 [需驗證] 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 上傳圖片
- 介紹上傳圖片的功能
- 查看管理控制台 [需驗證] API 文件 > 上傳圖片
上傳表單是使用傳統的 <form> 標籤上傳的時候會用到
action 是 API 路徑、enctype 是使用 form-data 格式、method 使用的方法是 post
input 欄位有 name 屬性是 file-to-upload,是 API 上傳檔案需要對應的欄位
表單傳送 action - 在 ProductModal.vue 檔案
觀看上傳圖片部分的程式碼,提及<input> @chagne=”uploadFile”、uploadFile() 方法 - 著重在如何把檔案取出,並轉成 form-data 格式
首要條件是取得 input 的檔案內容,在這 <input> 先定義 ref=”fileInput”,便於取得這個 DOM 元素,並且把檔案取出來使用 - 在 ProductModal.vue 檔案 JS 部分著重在 uploadFile 函式
把 input 裡面的內容取出
使用 console.dir(uploadFile) 查看,上傳任意圖片測試,查找 files 屬性,是一個陣列,要取得的是第0個檔案,再次上傳任意圖片測試 - 取出的檔案轉成 form-data 格式
宣告 formData 變數使用 new FormData() JS 方法
formData.append,append 是要增加一個欄位到表單裡面,欄位的名稱是 API 文件 name=”file-to-upload”,欄位內容是取出來的檔案 - 透過 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 整合相同程式碼
- 元件裡面有相同的程式碼要怎麼樣進行合併
- 試著完成 delModal 功能
- DelModal.vue 和 ProductModal.vue 相同片段的程式碼
使用 vue 的特性 mixin 把相同的程式碼抽離出來 - 在 src 新增資料夾 mixins
在 mixins 新增檔案 modalMixin.js - 抽離 DelModal.vue 檔案相同程式碼的部分到 modalMixin.js 檔案
- 在 DelModal.vue 檔案匯入 modalMixin.js 檔案
在元件加入 mixins 屬性,是一個陣列
mixins: [modalMixin] - 使用相同的方式把 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>
加入讀取的視覺效果
- 使用 vue3-loading-overlay 套件
- 安裝 vue3-loading-overlay 套件
npm install vue3-loading-overlay - 查看文件是如何使用讀取效果
- 載入元件 – Import component
複製 vue3-loading-over Usage 的程式碼
打開 main.js 檔案,然後貼上程式碼
修改程式碼路徑
註冊元件,使用全域註冊方式啟用元件 - 在 Products.vue 檔案把 Loading 元件加到最上方
加上 active 狀態,這是一個 props,實際上會把自定義的狀態給傳進去,自定義狀態名稱
把狀態加到 data() 裡面 - 預期在取得產品列表的時候將讀取的效果顯示
在 getProducts() 將讀取的效果加上 - 可以為所有的 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>
加入錯誤的訊息回饋
- 錯誤通知算是相對複雜的章節
主要原因會牽動到非常多的檔案的互相溝通 - 使用 Bootstrap 吐司 (Toasts) 元件
元件 > 吐司 > 方法 - 透過講解的形式了解吐司元件是怎麼運作
吐司元件跟 Vue 元件如何互動
目的是讓所有的元件都可以使用這個通知的功能
通知功能不會只掛載在特定的元件下,會獨立放在任何可以呼叫到的地方,是獨立元件
可以堆疊產生多個通知
分離可以產生堆疊,每個吐司有自己獨立的生命週期,啟用的時候可以使用參數
產品列表與吐司元件可以使用到 mitt 套件,進行跨元件的溝通 - 原始碼是如何運作
安裝 mitt 套件,功能為跨元件溝通使用
新增 emitter.js 檔案、撰寫程式碼
在元件利用的時候可以只加在最外層,在 Dashboard 檔案匯入 emitter,使用 provide 讓內層的元件都可以使用外層功能
在 Products.vue 檔案使用 inject
在 ToastMessages.vue 檔案使用 inject - 新增 ToastMessages.vue 檔案以及程式碼內容
作為定位使用、列表呈現
在 mounted() 生命週期加上 emitter 事件 - 新增 Toast.vue 檔案以及程式碼內容
吐司元件
從 Bootstrap 文件複製程式碼
最重要的地方每次吐司元件生成的時候,觸發屬於自己生命周期的時候,吐司元件會開啟 6 秒鐘後消失 - Products 如何把訊息傳給吐司元件
如何透過 mitt 送到吐司列表裡面
在 Products.vue 檔案撰寫程式碼,根據判斷狀態推送不同的訊息內容 - 可以參考課程範例逐步拆解流程該怎麼樣運作
// 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>
加入分頁切換
- 分頁會使用元件的方式製作
在之後的訂單、優惠券也可使用分頁的功能 - 關於分頁是怎麼運作
透過 API 形式進行頁面的切換
查看管理控制台 [需驗證] 文件 > 取得商品列表 - 示範直接透過分頁的參數切換頁面
在 Products.vue 檔案 getProduct() 直接加入參數切換 - 在 getProduct() 加上參數預設值的形式 page = 1
api 路徑就可以把變數載進來
查看 Console 顯示的訊息 pagination、products
將後端傳來製作分頁所需要的重要資訊製作成元件 - 新增 Pagination.vue 檔案和撰寫分頁元件程式碼
關於指令、JS 自己練習撰寫 - 在 Products.vue 檔案加入 <Pagination> 標籤
在外層定義 Products 元件,在內層定義 Pagination 元件,把分頁所需要的資訊傳進去,使用 props 的形式把分頁的資訊傳進去 props:pagination,就是從 AJAX 娶回來的相關資訊。在點擊分頁的時候會觸發 emit,執行頁面切換使用,會回傳 emits 觸發 getProduct 事件、同時把頁碼帶進來,透過這種方式進行頁面的切換 - 在 Pagination.vue 檔案預期外面會傳入一個 pages,這個就是 pagination,刻意用不同的名稱來代表,在這裡會傳入一個 pagination,是從 AJAX 所取回來的資料
- 在 Products.vue 檔案
在產品頁面的 getProducts 之後是有把 pagination 的資訊存起來,存起來的內容可以在 <Pagination> 標籤帶進來,重點: 前內後外的概念,前面放入內層所需要接收的資訊,外層帶入 pagination - 在 Pagination 檔案把頁碼加到這個區塊
在 AJAX 所取回來的資料 pagination 裡面有 total_pages 變數,就可以把變數帶進來,使用 v-for 的形式、page in pages 從外層帶進來的 props 名稱 pages 然後找到 total_pages,使用 v-for 要再加上 key,帶入的是 page 名稱,在 <a> 連結把 page 帶進來 - 預期點下按鈕時會切換頁面
使用 @click.prevent 加上一個自定義事件的名稱 updatePage 並且把頁碼帶進去
調整一下函式的內容,會直接對外發送切換頁面的事件
this.$emit(’emit-pages’, page) - 在 Products.vue 檔案做主要的切換,預期會透過 emit 事件把事件往外送,在 <Pagination> 標籤加上由前內後外,內層事件名稱,預期切換 Products、觸發 getProducts 的事件,外層 getProducts 事件
- 在 Pagination.vue 檔案加上 active 樣式判斷
- 上一頁、下一頁自行製作
// 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">«</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">»</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>
套用全域的千分號
- 數值超過一千以上建議加上千分號,在閱讀上面會比較好,在產品頁面、訂單、優惠券…等都有可能會加入千分號,會建議把千分號功能獨立起來,在每一個頁面、元件都可以使用
- 新增 filters.js 檔案
加入千分號方法、轉換時間格式方法 - 在 Products.vue 檔案有 origin_price、price
把函式加入轉換成千分號的形式
匯入 filters.js 檔案,在 Products.vue 檔案只需要匯入 currency 方法
將 currency 加到 methods 方法裡面就可以直接使用
currency 加在金額的前方,因為 currency 本身是函式必須使用括號 - 額外的技巧,在 Vue3 官方文件有應用配置 > globalProperties,是全域的屬性
可以使用 app.config.globalProperties 定義一個全域的屬性的方法,自定義任何想加入的屬性
在每個子元件下都可以直接去使用 this 方式呼叫這個變數,如果是加入一般的純值效益其實不大,加入方法的話,就像千分號方法使用度就會高很多 - 實作額外的技巧
在 main.js 檔案就可以把 filters.js 方法匯入
參考官方文件 app.config 設定
app.config.globalProperties 然後加上一個自定義的屬性名稱,使用 $filters 這個名稱作為一個集合,建議在屬性名稱最前方加上$,這樣比較不會跟區域元件裡面的變數產生衝突,等於一個物件,就可以把 currency 方法帶進來 - filters.js 其實有很多方法,可以陸續加入許多方法,都可以透過這種方式加到 filters 物件裡面
- 在 Products.vue 檔案,原本是直接呼叫 currency 方法,現在會在前面加上 $filters,可自定義屬性名稱
- 使用 $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');
中場加速
- 完成訂單、優惠券的頁面
- 訂單 – 訂單列表、編輯訂單、刪除訂單、分頁功能
- 在 Products.vue 檔案
在每次發送 http 行為的時候,都會針對事件成功與否觸發 emitter,emitter 就會把成功與否透過吐司的方式呈現,封裝方法就放在 pushMessageState.js - 在 main.js 檔案加入全域的屬性
app.config.globalProperties.$httpMessageState = $httpMessageState;
加入後就可以在每個程式碼都可以直接呼叫這個方法
注意: 這個方法是整合 AJAX 的一些錯誤事件,統一整理發送給 Toast 使用,正常來說不太建議把太多的方法掛在全域下面,會不知道這個方法來自於哪裡
可以使用 provide 來處理 - 如何透過 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 取得訂單列表
用戶端產品列表
- 製作客戶購物的部分
查看 API 文件 – 客戶購物 [免驗證]
購物流程: 取得商品列表、單一商品細節、加入購物車、刪除某一筆購物車資料、套用優惠券、結帳頁面、結帳付款…等 - 提醒: 點選任何一個產品的時候,請用單一頁面呈現
- 在 index.js 路由表加上用戶端路由
新增 user 路徑,不需要驗證流程會放在 user 路徑下 - 建立 Userboard.vue 檔案並加入程式碼
相較於 Dashboard 較簡單 - 建立 UserCart.vue 檔案並加入程式碼
在 user 路徑新增子路由 cart 路徑,對應的是購物車
建立 UserProduct.vue 檔案並加入程式碼
在 user 路徑新增子路由 product/:productId 路徑,對應的是單一產品頁面 - 講解 UserCart.vue 檔案
methods 方法的函式,getProducts() 取得商品列表、getProduct(id) 進入單一商品的頁面 - 試著把用戶端商品列表呈現
// 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>
用戶端加入購物車
- 撰寫加入購物車的行為 api
- 查看客戶購物 [免驗證] 文件 > 加入購物車
- 在 UserCart.vue 檔案撰寫程式碼
使用 @click 方式觸發加入購物車事件、addCart 方法,把產品的 id 帶進來
在 JS methods 方法新增 addCart 方法,測試 id 有沒有正確的取出
載入 api、建立資料 product_id, qty、發送 api
資料參數格式有加入 data 屬性名稱 - 避免用戶重複點擊加入購物車,加入單一按鈕的讀取效果,當 loadingItem 為一個特定品項的時候按鈕會轉為 disabled 並加上讀取的狀態
- 調整 HTML 的部分加上
:disabled=”this.status.loadingItem === item.id”
這個屬性會因為 loadingItem 的品項跟當前 item.id 的品項相同的時候,就會把加入到購物車按鈕轉為 disabled 的狀態 - 針對點擊購物車後加上額外的讀取效果
在 Bootstrap 文件 > Components > Spinners,選擇放射狀的樣式加到程式碼,尺寸需要調整一下選擇最小的、調整色彩
當前品項加入判斷式,v-if=”this.status.loadingItem === item.id”
白色對比度不佳,轉換成紅色 - 用戶無法重複點擊,了解目前是讀取狀態
如何加入購物車、如何把特定的按鈕加上讀取的效果 - 試著加入購物車、特定頁面的加入購物車功能完成
// 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
- 製作購物車列表
- 提醒: api 細節,把購物車 api 打,會發現這邊的結構和先前的不太一樣,這裡是 data 裡面還有 data,主要是因為這個 data 裡面有兩個結構,一個是購物車的品項,加入非常多的項目都會存在這個 carts 裡面
- 在畫面的部分可以看到非常多的項目
在 Console 查詢顯示的資料, carts、final_total、total
carts、final_total, total 這兩個項目需要存下來,可以存在一個物件裡面,或者拆成兩個來另外做儲存,都是必要出現在畫面上的項目 - 在 UserCart.vue 檔案撰寫 getCart() 方法
this.cart = response.data.data 所有的項目都存起來,這個 cart 就包含陣列的列表以及總金額
在購物車列表就使用 v-if 判斷陣列有沒有存在,如果存在的情況下就使用 v-for 把陣列的內容完整的呈現 - 數量調整的功能尚未製作
- 使用 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');
用戶端更新購物車數量品項
- 看資料的部分
使用 Vue.js devtools 查看元件裡面的資料,購物車品項
<RouterView> > <UserCart> - 在 UserCart.vue 檔案
可以看到 v-model.number=”item.qty”,item 指的是 product 品項,目前品項可以透過 input 來調整數量多寡,Vue.js devtools 的 qty 也會跟著做調整,目前 input 已經跟資料綁上 - 現在有個問題是調整數量的時候,是可以變為 0 或者負值的,要避免變成負值,送出才不會產生問題,在 input 必須加上最小值 min = 1,避免用戶將值調成低於 0 的狀態
- 看購物車的數量,當調整數量的時候,qty 數值是會跟著做改變,但是價格是不會跟著做改變,主要是因為價格是由後端所計算出,因此必須調整後端的購物車品項的數量,才會回傳新的價格
- 查看 API 文件更新購物車的部分,更新購物車 api 跟加入購物車很像,在更新購物車會加入購物車的 id,單一品項的 id,所傳遞的參數內容和加入購物車是一樣的
- 在 Vue.js devtools 購物車裡面有 id、產品的 id,這個 id 就是在購物車裡面單一品項的 id,另一個是產品的 id,因此必須把這兩個資訊傳到後端,才能計算新的金額
- 在程式碼數量的地方加上事件,使用 change 方法觸發更新購物車 @change=”updateCart()”,因為要傳送相關的資訊,購物車的品項 id 以及產品 id,在這個地方把 item 帶進來
- 在 JS 部分撰寫 updateCart() 方法
加上 api 路徑、api 方法,會使用 put 方法
組裝要傳遞的資訊,透過物件方式來進行傳遞
使用 console 測試內容有沒有確實更新,在更新購物車項目之後,可以再重新取得完整購物車的內容 - 避免用戶重複點擊,加上讀取的效果
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>
用戶端套用優惠券
- 要使用優惠券的功能務必先把優惠券的後台完成,才可以套用優惠券的功能
至少先新增一個優惠券
優惠券名稱、到期日、是否啟用、折扣百分比會直接套用在購物車內的所有的品項 - 在購物車列表 console 看一下關於購物車的內容
購物車品項有兩個價格 total、final_total,實際上套用優惠券會把 final_total 金額重新計算,再加總到最終的總金額,品項最終會套用到 final_total 裡面重新運算 - 以範例講解套用優惠券試試,看一下重新取得的列表內容,cart 所更新的購物車內容 final_total 的金額成功打折,另外把優惠券相關內容加入進來,品項最終會用打折後的價格方式進行計算
- 試著完成優惠券功能
套用優惠券按鈕加上 addCouponCode 方法,在程式碼把 addCouponCode() 方法加入
查看 API 文件套用優惠券 api,會發送 data 內容,data 裡面有一個 code 的屬性就是優惠券的名稱,優惠券名稱放在這個地方送出就可以了
api 路徑、建立資料結構、this.$http.post 方法把 url 以及 coupon 內容帶進來,包在 data 的屬性下並且把上面的 coupon 帶進來,加上 then 回傳結果,然後重新取得購物車內容,把金額更新 - 加入購物車,重新整理後購物車列表就會新增一個品項
套用優惠券,查看 console、折扣相關訊息呈現在畫面 - 製作 Coupon 也可以把讀取的效果還有相關的回應加上
- 試著製作刪除的功能
// 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>
用戶端建立訂單
- 購物車已經有部分的品項
- 製作結帳的部分
- 建立結帳表單結構
- 安裝、套用 VeeValidate
npm i vee-validate @vee-validate/rules @vee-validate/i18n –save
注意: import * as AllRules from ‘@vee-validate/rules’; - 查看購物車 api 的部分,使用結帳頁面的 api
結帳頁面 [參數] – data、message,建立表單的時候結構要注意 - 在 UserCart.vue 檔案建立結構是使用 form 物件包著 user 以及 message 區塊
- 建立訂單在 <Form> 標籤加上 @submit 行為觸發強制驗證,使用 createOrder 方法
在程式碼 methods 方法加上 createOrder 方法,把 createOrder 加到 message 裡面,把 @submit 加在 <Form> 標籤上的好處是當送出表單的時候會強制先進行驗證,除非內容都符合資格,否則表但不會送出
在 createOrder 可以先把 api 的網址建立起來
建立表單的結構
使用 this.$http.post 方法,帶上 url 路徑、使用物件把 data 屬性帶上 order 的方式把表單內容送出
使用 console 查看回應有沒有正確 - 送出訂單要先確定購物車裡面是有品項,再把下面的內容填寫,送出表單查看回應的結果
// 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>
用戶端結帳流程
- 建立新的一筆訂單,並填好用戶表端資料,預期在送出訂單之後會產一個 orderId,複製 orderId 的值等下使用,送出訂單之後預期會轉只到另外一個頁面,checkout 路由下的頁面,讓用戶確認產品還有表單內容是否正確,再進行結帳的流程,現在的流程是必須先把 checkout 加上再讓用戶進行結帳
- 新增 UserCheckout.vue 檔案撰寫程式碼
完成 checkout 頁面,上方是產品的內容,在購物車裡面的品項,下方是用戶自己所填的細節,會透過 orderId 把資訊取回來 - 著重在 JS 的部分
- 在 index.js 檔案加上子路由 checkout/:orderId,orderId 作用是取回原本同一筆訂單使用的
- 直接使用網址路徑進入用戶端結帳頁面
查看 API 文件取得某一筆訂單,透過 orderId 取得相同一筆訂單內容
必須先把路由上面的 id 取出來,在 data() 資料定義 orderId,在 created() 生命週期撰寫 this.orderId = this.$route.params.orderId 把 orderId 取出
使用 console 查看,orderId 是從網址列所取得
加上 this.getOrder() 方法,會觸發 methods 裡面的 getOrder(),會透過 getOrder 取得需要的相關內容 - 在 getOrder() 方法撰寫取得某一筆訂單
api 路徑取得特定訂單的內容、使用 this.$http.get 方法取得這筆訂單內容
使用 console 是否有正確取得
將相關的內容呈現在畫面上 - 製作確認付款去的行為
先看 order 包含什麼資訊,新增品項的時間、id、總金額、is_paid 屬性,is_paid 是用戶是否已經付款,is_paid只能由後端進行調整
用戶觸發結帳付款 api,觸發之後就會把 is_paid 的 false 改為 true
結帳付款 api 主要的重點在 orderId,會透過 orderId 搭配前一個 pay api 將這筆訂單的 is_paid 改為 true - 針對這筆訂單進行付款
在 <form> 表單加上 payOrder 方法,當用戶按下按鈕或是送出表單的時候,就會觸發 payOrder 行為
撰寫 payOrder() 方法
url 路徑、使用 this.$http.post 方法將這筆訂單進行付款,發出請求後會使用 then 接收回傳
將這筆訂單完成之後就不能再次使用,重整再重新取得訂單的項目,再重新觸發一次 getOrder 行為 - 按下確認付款去之後,會觸發一個行為,會出現付款已經完成,在畫面上付款狀態會改成付款完成,確認付款去按鈕就會隱藏
- 查看 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 需增加自己創意在內
- 行動版、桌面版皆需製作
- 已正式上線為目標
- 退件率高
- 請標示作為求職作品使用
作品製作建議
先決定主題
- 搜集素材
- 確認主題製作可行性
- 定義產品資訊
- 構思技術小巧思 (我的最愛、抽獎、地圖功能、倒數優惠…)
- 開始執行
素材資源
- Bootstrap、Bootstrap 中文版
- Material Design、Material Design 中文版
- 圖庫
- Material Design Icon
- Bootstrap Icon
- FontAwesome
- 付費 icon
- 字體 (無襯線、san-serif、黑體)
你的用心,會讓作品更加耀眼
盡可能避免的錯誤
- 缺少文案、作品不完整
- 沒有對齊、畫面跑版
- 使用者體驗的問題: 例如購物車為空時,避免讓用戶進入下一步
- 行動版無法運作
不知如何下手嗎
萬丈高樓平地起,規劃優於一切
- 先參考學長姐的作品
- 從搜集資料開始,規劃網站的文案、產品、各種小巧思
- 先製作版型,將流程一一的順好
- 將功能一一套上版型
- 最後修正,提交作業