├── vue.config.js ├── src ├── bus.js ├── assets │ ├── image │ │ ├── logo.png │ │ ├── anniversary8.jpg │ │ ├── castorland.jpg │ │ ├── clementoni.jpg │ │ └── ravensburger.jpg │ └── scss │ │ ├── all.scss │ │ ├── helpers │ │ ├── _base.scss │ │ └── _loading.scss │ │ ├── _coupons.scss │ │ ├── _dashboard.scss │ │ └── _index.scss ├── App.vue ├── components │ ├── CategoryList.vue │ ├── Dashboard.vue │ ├── Pagination.vue │ ├── Sidebar.vue │ ├── Cards.vue │ ├── Navbar.vue │ └── MessageAlert.vue ├── main.js ├── views │ ├── frontend │ │ ├── Login.vue │ │ ├── Coupons.vue │ │ ├── Products.vue │ │ ├── About.vue │ │ ├── Like.vue │ │ ├── Checkout.vue │ │ ├── Product.vue │ │ ├── Home.vue │ │ └── Cart.vue │ └── backend │ │ ├── TestCheckout.vue │ │ ├── AdminOrders.vue │ │ ├── AdminCoupons.vue │ │ ├── AdminProducts.vue │ │ └── TestOrders.vue └── router │ └── index.js ├── .env ├── public ├── favicon.ico ├── RWD-thumbnail.png └── index.html ├── babel.config.js ├── .editorconfig ├── .gitignore ├── jsconfig.json ├── package.json └── README.md /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: './' 3 | } 4 | -------------------------------------------------------------------------------- /src/bus.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | Vue.prototype.$bus = new Vue() 4 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VUE_APP_APIPATH = https://vue-course-api.hexschool.io/ 2 | VUE_APP_MYPATH = wangxuan 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WangShuan/vue-portfolio-v3/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /public/RWD-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WangShuan/vue-portfolio-v3/HEAD/public/RWD-thumbnail.png -------------------------------------------------------------------------------- /src/assets/image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WangShuan/vue-portfolio-v3/HEAD/src/assets/image/logo.png -------------------------------------------------------------------------------- /src/assets/image/anniversary8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WangShuan/vue-portfolio-v3/HEAD/src/assets/image/anniversary8.jpg -------------------------------------------------------------------------------- /src/assets/image/castorland.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WangShuan/vue-portfolio-v3/HEAD/src/assets/image/castorland.jpg -------------------------------------------------------------------------------- /src/assets/image/clementoni.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WangShuan/vue-portfolio-v3/HEAD/src/assets/image/clementoni.jpg -------------------------------------------------------------------------------- /src/assets/image/ravensburger.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WangShuan/vue-portfolio-v3/HEAD/src/assets/image/ravensburger.jpg -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 22 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*" 4 | ], 5 | "compilerOptions": { 6 | "baseUrl": ".", 7 | "paths": { 8 | "@/*": [ 9 | "src/*" 10 | ] 11 | } 12 | }, 13 | "eslint.autoFixOnSave": true, 14 | "eslint.validate": [{ 15 | "language": "javascript", 16 | "autoFix": true 17 | }, 18 | { 19 | "language": "javascriptreact", 20 | "autoFix": true 21 | }, 22 | { 23 | "language": "vue", 24 | "autoFix": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /src/assets/scss/all.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/functions"; 2 | @import "./helpers/_var"; 3 | @import "~bootstrap/scss/bootstrap"; 4 | @import "./helpers/_loading"; 5 | @import "./helpers/_base"; 6 | @import "./_dashboard"; 7 | @import "./_index"; 8 | 9 | .list-group-item:hover { 10 | cursor: pointer; 11 | background-color: $light; 12 | } 13 | 14 | .sm { 15 | display: none; 16 | } 17 | 18 | @media screen and (max-width: 420px) { 19 | .lg { 20 | display: none; 21 | } 22 | 23 | .sm { 24 | display: block; 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /src/assets/scss/helpers/_base.scss: -------------------------------------------------------------------------------- 1 | #app { 2 | text-align: center; 3 | } 4 | 5 | .table th, 6 | .table td { 7 | vertical-align: inherit; 8 | } 9 | 10 | .table-striped { 11 | tbody tr:nth-of-type(#{$table-striped-order}) { 12 | background-color: $light; 13 | } 14 | } 15 | 16 | .text-lg { 17 | font-size: 1.2em; 18 | } 19 | 20 | 21 | .card-footer { 22 | background-color: lighten($light, 10%); 23 | } 24 | 25 | pre { 26 | white-space: pre-wrap; 27 | white-space: -moz-pre-wrap !important; 28 | white-space: -pre-wrap; 29 | white-space: -o-pre-wrap; 30 | word-wrap: break-word; 31 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 拼圖迷 - 進口拼圖專賣店 11 | 12 | 13 | 14 | 15 | 16 | 20 |
21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/CategoryList.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 32 | -------------------------------------------------------------------------------- /src/components/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 36 | 37 | 46 | -------------------------------------------------------------------------------- /src/components/Pagination.vue: -------------------------------------------------------------------------------- 1 | 38 | 51 | -------------------------------------------------------------------------------- /src/assets/scss/_coupons.scss: -------------------------------------------------------------------------------- 1 | .sawtooth { 2 | position: relative; 3 | overflow: hidden; 4 | width: 95%; 5 | height: 106px; 6 | margin: auto; 7 | display: inline-block; 8 | 9 | &-big { 10 | width: 35%; 11 | max-height: 90px; 12 | 13 | &>h1 { 14 | color: #c92104; 15 | text-shadow: 2px 0 0 #ccc; 16 | } 17 | } 18 | 19 | &-content { 20 | position: absolute; 21 | right: 0; 22 | z-index: 999; 23 | top: 0; 24 | width: 65%; 25 | } 26 | } 27 | 28 | .sawtooth:before, 29 | .sawtooth:after { 30 | content: ""; 31 | width: 0; 32 | height: 100%; 33 | position: absolute; 34 | top: 10px; 35 | } 36 | 37 | .sawtooth:before { 38 | border-right: 10px dotted white; 39 | left: -5px; 40 | } 41 | 42 | .sawtooth:after { 43 | border-left: 10px dotted white; 44 | right: -5px; 45 | } 46 | 47 | .card { 48 | overflow: hidden; 49 | position: relative; 50 | border-radius: 0 0 50% 50%/10% 10% 25% 25%; 51 | box-shadow: 5px 5px 5px #ccc; 52 | border-bottom: 0; 53 | } 54 | 55 | .index { 56 | z-index: 1; 57 | } 58 | 59 | .bg-icon { 60 | width: 120px; 61 | height: 120px; 62 | overflow: hidden; 63 | float: right; 64 | margin-top: -20px; 65 | margin-right: -15px; 66 | opacity: .4; 67 | } 68 | 69 | .icon-big { 70 | font-size: 120px; 71 | } 72 | 73 | .sm-bg-icon{ 74 | opacity: 0.4; 75 | position: absolute; 76 | right: -5%; 77 | top: 40%; 78 | z-index: 0; 79 | } 80 | 81 | .input-bg { 82 | background: transparent; 83 | font-size: 1.5em; 84 | width: 180px; 85 | padding: 0; 86 | border: 0; 87 | color: white; 88 | font-weight: bolder; 89 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-shop", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.21.0", 12 | "bootstrap": "^4.5.3", 13 | "core-js": "^3.6.5", 14 | "jquery": "^3.5.1", 15 | "popper.js": "^1.16.1", 16 | "vee-validate": "^3.4.5", 17 | "vue": "^2.6.11", 18 | "vue-axios": "^2.1.5", 19 | "vue-loading-overlay": "^3.4.2", 20 | "vue-router": "^3.2.0" 21 | }, 22 | "devDependencies": { 23 | "@vue/cli-plugin-babel": "~4.5.0", 24 | "@vue/cli-plugin-eslint": "^4.5.9", 25 | "@vue/cli-plugin-router": "^4.5.9", 26 | "@vue/cli-service": "~4.5.0", 27 | "@vue/eslint-config-standard": "^5.1.2", 28 | "babel-eslint": "^10.1.0", 29 | "eslint": "^6.7.2", 30 | "eslint-plugin-import": "^2.20.2", 31 | "eslint-plugin-node": "^11.1.0", 32 | "eslint-plugin-promise": "^4.2.1", 33 | "eslint-plugin-standard": "^4.0.0", 34 | "eslint-plugin-vue": "^6.2.2", 35 | "node-sass": "^4.12.0", 36 | "sass-loader": "^8.0.2", 37 | "vue-template-compiler": "^2.6.11" 38 | }, 39 | "eslintConfig": { 40 | "root": true, 41 | "env": { 42 | "node": true 43 | }, 44 | "extends": [ 45 | "plugin:vue/essential", 46 | "eslint:recommended", 47 | "@vue/standard" 48 | ], 49 | "parserOptions": { 50 | "parser": "babel-eslint" 51 | }, 52 | "rules": {} 53 | }, 54 | "browserslist": [ 55 | "> 1%", 56 | "last 2 versions", 57 | "not dead" 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import axios from 'axios' 3 | import VueAxios from 'vue-axios' 4 | import VueLoading from 'vue-loading-overlay' 5 | import 'bootstrap' 6 | 7 | import 'vue-loading-overlay/dist/vue-loading.css' 8 | import App from './App.vue' 9 | import router from './router' 10 | 11 | import './bus' 12 | import { ValidationObserver, ValidationProvider, extend, localize } from 'vee-validate' 13 | import * as rules from 'vee-validate/dist/rules' 14 | import TW from 'vee-validate/dist/locale/zh_TW.json' 15 | 16 | Object.keys(rules).forEach((rule) => extend(rule, rules[rule])) 17 | 18 | localize('zh_TW', TW) 19 | 20 | Vue.component('ValidationObserver', ValidationObserver) 21 | Vue.component('ValidationProvider', ValidationProvider) 22 | 23 | Vue.config.productionTip = false 24 | Vue.use(VueAxios, axios) 25 | axios.defaults.withCredentials = true 26 | 27 | Vue.component('loading', VueLoading) 28 | 29 | Vue.filter('dollarSign', (n) => `$${n}`) 30 | 31 | Vue.filter('numFormat', (n) => { 32 | const intPart = Number(n).toFixed(0) 33 | const intPartFormat = intPart 34 | .toString() 35 | .replace(/(\d)(?=(?:\d{3})+$)/g, '$1,') 36 | return intPartFormat 37 | }) 38 | 39 | router.beforeEach((to, from, next) => { 40 | if (to.meta.title) { 41 | document.title = to.meta.title 42 | } 43 | if (to.meta.requiresAuth) { 44 | const api = `${process.env.VUE_APP_APIPATH}api/user/check` 45 | axios.post(api).then((res) => { 46 | if (res.data.success === false) { 47 | next('/login') 48 | } else { 49 | next() 50 | } 51 | }) 52 | } else { 53 | next() 54 | } 55 | }) 56 | 57 | new Vue({ 58 | router, 59 | render: (h) => h(App) 60 | }).$mount('#app') 61 | -------------------------------------------------------------------------------- /src/components/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 50 | -------------------------------------------------------------------------------- /src/components/Cards.vue: -------------------------------------------------------------------------------- 1 | 52 | 74 | -------------------------------------------------------------------------------- /src/assets/scss/_dashboard.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: .875rem; 3 | } 4 | 5 | .feather { 6 | width: 16px; 7 | height: 16px; 8 | vertical-align: text-bottom; 9 | } 10 | 11 | /* 12 | * Sidebar 13 | */ 14 | 15 | .sidebar { 16 | position: fixed; 17 | top: 0; 18 | bottom: 0; 19 | left: 0; 20 | z-index: 100; 21 | /* Behind the navbar */ 22 | padding: 48px 0 0; 23 | /* Height of navbar */ 24 | box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); 25 | } 26 | 27 | .sidebar-sticky { 28 | position: relative; 29 | top: 0; 30 | height: calc(100vh - 48px); 31 | padding-top: .5rem; 32 | overflow-x: hidden; 33 | overflow-y: auto; 34 | /* Scrollable contents if viewport is shorter than content. */ 35 | } 36 | 37 | @supports ((position: -webkit-sticky) or (position: sticky)) { 38 | .sidebar-sticky { 39 | position: -webkit-sticky; 40 | position: sticky; 41 | } 42 | } 43 | 44 | .sidebar .nav-link { 45 | font-weight: 500; 46 | color: #333; 47 | } 48 | 49 | .sidebar .nav-link .feather { 50 | margin-right: 4px; 51 | color: #999; 52 | } 53 | 54 | .sidebar .nav-link.active { 55 | color: #007bff; 56 | } 57 | 58 | .sidebar .nav-link:hover .feather, 59 | .sidebar .nav-link.active .feather { 60 | color: inherit; 61 | } 62 | 63 | .sidebar-heading { 64 | font-size: .75rem; 65 | text-transform: uppercase; 66 | } 67 | 68 | /* 69 | * Content 70 | */ 71 | 72 | [role="main"] { 73 | padding-top: 133px; 74 | /* Space for fixed navbar */ 75 | } 76 | 77 | @media (min-width: 768px) { 78 | [role="main"] { 79 | padding-top: 48px; 80 | /* Space for fixed navbar */ 81 | } 82 | } 83 | 84 | /* 85 | * Navbar 86 | */ 87 | 88 | .navbar-brand { 89 | padding-top: .75rem; 90 | padding-bottom: .75rem; 91 | font-size: 1rem; 92 | background-color: rgba(0, 0, 0, .25); 93 | box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25); 94 | } 95 | 96 | .navbar .form-control { 97 | padding: .75rem 1rem; 98 | border-width: 0; 99 | border-radius: 0; 100 | } 101 | 102 | .form-control-dark { 103 | color: #fff; 104 | background-color: rgba(255, 255, 255, .1); 105 | border-color: rgba(255, 255, 255, .1); 106 | } 107 | 108 | .form-control-dark:focus { 109 | border-color: transparent; 110 | box-shadow: 0 0 0 3px rgba(255, 255, 255, .25); 111 | } -------------------------------------------------------------------------------- /src/assets/scss/helpers/_loading.scss: -------------------------------------------------------------------------------- 1 | @keyframes ldio-dwsbgiuamos { 2 | 0% { 3 | opacity: 1; 4 | backface-visibility: hidden; 5 | transform: translateZ(0) scale(1.5, 1.5); 6 | } 7 | 8 | 100% { 9 | opacity: 0; 10 | backface-visibility: hidden; 11 | transform: translateZ(0) scale(1, 1); 12 | } 13 | } 14 | 15 | .ldio-dwsbgiuamos div>div { 16 | position: absolute; 17 | width: 28px; 18 | height: 28px; 19 | border-radius: 50%; 20 | background: #111111; 21 | animation: ldio-dwsbgiuamos 1s linear infinite; 22 | } 23 | 24 | .ldio-dwsbgiuamos div:nth-child(1)>div { 25 | left: 146px; 26 | top: 86px; 27 | animation-delay: -0.875s; 28 | } 29 | 30 | .ldio-dwsbgiuamos>div:nth-child(1) { 31 | transform: rotate(0deg); 32 | transform-origin: 160px 100px; 33 | } 34 | 35 | .ldio-dwsbgiuamos div:nth-child(2)>div { 36 | left: 128px; 37 | top: 128px; 38 | animation-delay: -0.75s; 39 | } 40 | 41 | .ldio-dwsbgiuamos>div:nth-child(2) { 42 | transform: rotate(45deg); 43 | transform-origin: 142px 142px; 44 | } 45 | 46 | .ldio-dwsbgiuamos div:nth-child(3)>div { 47 | left: 86px; 48 | top: 146px; 49 | animation-delay: -0.625s; 50 | } 51 | 52 | .ldio-dwsbgiuamos>div:nth-child(3) { 53 | transform: rotate(90deg); 54 | transform-origin: 100px 160px; 55 | } 56 | 57 | .ldio-dwsbgiuamos div:nth-child(4)>div { 58 | left: 44px; 59 | top: 128px; 60 | animation-delay: -0.5s; 61 | } 62 | 63 | .ldio-dwsbgiuamos>div:nth-child(4) { 64 | transform: rotate(135deg); 65 | transform-origin: 58px 142px; 66 | } 67 | 68 | .ldio-dwsbgiuamos div:nth-child(5)>div { 69 | left: 26px; 70 | top: 86px; 71 | animation-delay: -0.375s; 72 | } 73 | 74 | .ldio-dwsbgiuamos>div:nth-child(5) { 75 | transform: rotate(180deg); 76 | transform-origin: 40px 100px; 77 | } 78 | 79 | .ldio-dwsbgiuamos div:nth-child(6)>div { 80 | left: 44px; 81 | top: 44px; 82 | animation-delay: -0.25s; 83 | } 84 | 85 | .ldio-dwsbgiuamos>div:nth-child(6) { 86 | transform: rotate(225deg); 87 | transform-origin: 58px 58px; 88 | } 89 | 90 | .ldio-dwsbgiuamos div:nth-child(7)>div { 91 | left: 86px; 92 | top: 26px; 93 | animation-delay: -0.125s; 94 | } 95 | 96 | .ldio-dwsbgiuamos>div:nth-child(7) { 97 | transform: rotate(270deg); 98 | transform-origin: 100px 40px; 99 | } 100 | 101 | .ldio-dwsbgiuamos div:nth-child(8)>div { 102 | left: 128px; 103 | top: 44px; 104 | animation-delay: 0s; 105 | } 106 | 107 | .ldio-dwsbgiuamos>div:nth-child(8) { 108 | transform: rotate(315deg); 109 | transform-origin: 142px 58px; 110 | } 111 | 112 | .loadingio-spinner-spin-ur5grgaunp { 113 | width: 200px; 114 | height: 200px; 115 | display: inline-block; 116 | overflow: hidden; 117 | } 118 | 119 | .ldio-dwsbgiuamos { 120 | width: 100%; 121 | height: 100%; 122 | position: relative; 123 | transform: translateZ(0) scale(1); 124 | backface-visibility: hidden; 125 | transform-origin: 0 0; 126 | /* see note above */ 127 | } 128 | 129 | .ldio-dwsbgiuamos div { 130 | box-sizing: content-box; 131 | } 132 | 133 | /* generated by https://loading.io/ */ -------------------------------------------------------------------------------- /src/views/frontend/Login.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 82 | 83 | 115 | -------------------------------------------------------------------------------- /src/assets/scss/_index.scss: -------------------------------------------------------------------------------- 1 | .logo { 2 | height: 30px; 3 | } 4 | 5 | .main-img-lg { 6 | width: 100%; 7 | overflow: hidden; 8 | position: relative; 9 | 10 | &>img { 11 | height: 600px; 12 | object-fit: cover; 13 | opacity: 0.8; 14 | object-position: top; 15 | } 16 | 17 | &-caption { 18 | position: absolute; 19 | top: calc(50% - 160px); 20 | left: calc(50% - 270px); 21 | width: 540px; 22 | height: 320px; 23 | opacity: 0.7; 24 | padding: 30px; 25 | 26 | &>p { 27 | line-height: 1.5em; 28 | } 29 | } 30 | 31 | &-btn { 32 | font-size: 25px; 33 | z-index: 99999; 34 | opacity: 0.8; 35 | position: absolute; 36 | top: calc(50% + 90px); 37 | left: calc(50% + 80px); 38 | animation: jittery 1.15s infinite; 39 | } 40 | } 41 | 42 | .main-img-sm { 43 | width: 100%; 44 | overflow: hidden; 45 | position: relative; 46 | 47 | &>img { 48 | height: 65vh; 49 | object-fit: cover; 50 | object-position: top; 51 | } 52 | 53 | &-caption { 54 | overflow: hidden; 55 | position: absolute; 56 | bottom: 0; 57 | width: 100%; 58 | height: 230px; 59 | opacity: 0.7; 60 | background-color: $light; 61 | padding: 10px; 62 | 63 | &>p { 64 | line-height: 1.5em; 65 | } 66 | } 67 | 68 | &-btn { 69 | position: absolute; 70 | bottom: 15px; 71 | left: calc(50vw - 44px); 72 | animation: jittery-sm 1.15s infinite; 73 | } 74 | } 75 | 76 | .copy { 77 | width: 96px; 78 | background: none; 79 | border: 0; 80 | text-align: center; 81 | 82 | &:hover { 83 | cursor: pointer; 84 | } 85 | } 86 | 87 | @keyframes jittery { 88 | 89 | 5%, 90 | 50% { 91 | transform: scale(1); 92 | } 93 | 94 | 10% { 95 | transform: scale(0.9); 96 | } 97 | 98 | 15% { 99 | transform: scale(1.15); 100 | } 101 | 102 | 20% { 103 | transform: scale(1.15) rotate(-5deg); 104 | } 105 | 106 | 25% { 107 | transform: scale(1.15) rotate(5deg); 108 | } 109 | 110 | 30% { 111 | transform: scale(1.15) rotate(-3deg); 112 | } 113 | 114 | 35% { 115 | transform: scale(1.15) rotate(2deg); 116 | } 117 | 118 | 40% { 119 | transform: scale(1.15) rotate(0); 120 | opacity: 1; 121 | font-size: 25px; 122 | } 123 | } 124 | 125 | @keyframes jittery-sm { 126 | 127 | 5%, 128 | 50% { 129 | transform: scale(1); 130 | } 131 | 132 | 10% { 133 | transform: scale(0.9); 134 | } 135 | 136 | 15% { 137 | transform: scale(1.15); 138 | } 139 | 140 | 20% { 141 | transform: scale(1.15) rotate(-5deg); 142 | } 143 | 144 | 25% { 145 | transform: scale(1.15) rotate(5deg); 146 | } 147 | 148 | 30% { 149 | transform: scale(1.15) rotate(-3deg); 150 | } 151 | 152 | 35% { 153 | transform: scale(1.15) rotate(2deg); 154 | } 155 | 156 | 40% { 157 | transform: scale(1.15) rotate(0); 158 | opacity: 1; 159 | font-size: 1.15em; 160 | } 161 | } 162 | 163 | .main-content { 164 | min-height: calc(100vh - 190px) !important; 165 | } 166 | 167 | .sm-header { 168 | height: 60px; 169 | 170 | & .dropdown { 171 | &-toggle { 172 | line-height: 45px; 173 | } 174 | 175 | &-menu { 176 | top: 40px; 177 | right: 0; 178 | } 179 | 180 | &-item { 181 | padding: 10px 25px; 182 | } 183 | } 184 | } -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | import Home from '../views/frontend/Home.vue' 4 | 5 | Vue.use(VueRouter) 6 | 7 | const routes = [{ 8 | path: '/', 9 | name: 'Home', 10 | component: Home, 11 | meta: { 12 | title: '拼圖迷 - 進口拼圖專賣店' 13 | }, 14 | children: [{ 15 | path: 'about', 16 | name: 'About', 17 | component: () => import('../views/frontend/About.vue'), 18 | meta: { 19 | title: '拼圖迷 - 關於我們' 20 | } 21 | }, 22 | { 23 | path: 'cart', 24 | name: 'Cart', 25 | component: () => import('../views/frontend/Cart.vue'), 26 | meta: { 27 | title: '拼圖迷 - 購物車' 28 | } 29 | }, 30 | { 31 | path: 'products/:category', 32 | name: 'Products', 33 | component: () => import('../views/frontend/Products.vue'), 34 | meta: { 35 | title: '拼圖迷 - 產品列表' 36 | } 37 | }, 38 | { 39 | path: 'product/:id', 40 | name: 'Product', 41 | component: () => import('../views/frontend/Product.vue'), 42 | meta: { 43 | title: '拼圖迷 - 產品內容' 44 | } 45 | }, 46 | { 47 | path: 'login', 48 | name: 'Login', 49 | component: () => import('../views/frontend/Login.vue'), 50 | meta: { 51 | title: '拼圖迷 - 後台管理登入' 52 | } 53 | }, 54 | { 55 | path: 'coupon', 56 | name: 'Coupons', 57 | component: () => import('../views/frontend/Coupons.vue'), 58 | meta: { 59 | title: '拼圖迷 - 優惠券專區' 60 | } 61 | }, 62 | { 63 | path: 'checkout/:orderId', 64 | name: 'Checkout', 65 | component: () => import('../views/frontend/Checkout.vue'), 66 | meta: { 67 | title: '拼圖迷 - 結帳' 68 | } 69 | }, 70 | { 71 | path: 'like', 72 | name: 'Like', 73 | component: () => import('../views/frontend/Like.vue'), 74 | meta: { 75 | title: '拼圖迷 - 喜好項目' 76 | } 77 | } 78 | ] 79 | }, 80 | { 81 | path: '/admin', 82 | name: 'Dashboard', 83 | component: () => import('../components/Dashboard.vue'), 84 | meta: { 85 | requiresAuth: true 86 | }, 87 | children: [{ 88 | path: 'products', 89 | name: 'AdminProducts', 90 | component: () => import('../views/backend/AdminProducts.vue'), 91 | meta: { 92 | requiresAuth: true, 93 | title: '拼圖迷 - 產品管理' 94 | } 95 | }, { 96 | path: 'orders', 97 | name: 'AdminOrders', 98 | component: () => import('../views/backend/AdminOrders.vue'), 99 | meta: { 100 | requiresAuth: true, 101 | title: '拼圖迷 - 訂單管理' 102 | } 103 | }, { 104 | path: 'coupons', 105 | name: 'AdminCoupons', 106 | component: () => import('../views/backend/AdminCoupons.vue'), 107 | meta: { 108 | requiresAuth: true, 109 | title: '拼圖迷 - 優惠券管理' 110 | } 111 | }] 112 | }, 113 | { 114 | path: '/test', 115 | component: () => import('../components/Dashboard.vue'), 116 | children: [{ 117 | path: 'test_orders', 118 | name: 'TestOrders', 119 | component: () => import('../views/backend/TestOrders.vue'), 120 | meta: { 121 | title: '拼圖迷 - 模擬訂單' 122 | } 123 | }, 124 | { 125 | path: 'test_checkout/:orderId', 126 | name: 'TestCheckout', 127 | component: () => import('../views/backend/TestCheckout.vue'), 128 | meta: { 129 | title: '拼圖迷 - 模擬結帳' 130 | } 131 | } 132 | ] 133 | }, 134 | { 135 | path: '*', 136 | redirect: '/', 137 | meta: { 138 | title: '拼圖迷 - 進口拼圖專賣店' 139 | } 140 | } 141 | ] 142 | 143 | const router = new VueRouter({ 144 | routes 145 | }) 146 | 147 | export default router 148 | -------------------------------------------------------------------------------- /src/components/Navbar.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 107 | -------------------------------------------------------------------------------- /src/components/MessageAlert.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 132 | 133 | 166 | -------------------------------------------------------------------------------- /src/views/backend/TestCheckout.vue: -------------------------------------------------------------------------------- 1 | 112 | 113 | 157 | -------------------------------------------------------------------------------- /src/views/frontend/Coupons.vue: -------------------------------------------------------------------------------- 1 | 160 | 161 | 205 | 206 | 209 | -------------------------------------------------------------------------------- /src/views/frontend/Products.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 190 | 191 | 218 | -------------------------------------------------------------------------------- /src/views/frontend/About.vue: -------------------------------------------------------------------------------- 1 | 203 | 204 | 217 | 218 | 223 | -------------------------------------------------------------------------------- /src/views/backend/AdminOrders.vue: -------------------------------------------------------------------------------- 1 | 191 | 192 | 238 | 239 | 245 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue 出一個電商教學手冊 2 | 3 | ## 專案說明 4 | 5 | 該專案使用 `vue-cli v3` 以上版本 並添加了 `ESLint` 6 | 7 | * 若一開始沒有為專案添加 ESLint 可通過以下方式加入: 8 | 9 | 1. 開啟終端機 切到當前專案資料夾 10 | 11 | 2. 輸入 `vue add eslint` 12 | 13 | 3. 選擇風格(不熟悉者建議選擇 Standard 風格) 14 | 15 | ## 結構說明 16 | 17 | ### src 18 | 19 | 主要用到的檔案都在 `src` 這個資料夾中 20 | 21 | `src/main.js` 是入口文件 裡面處理各種插件的配置 22 | 23 | `src/router/index.js` 專門處理所有路由元件的檔案 24 | 25 | `src/App.vue` 是整個 vue 實例 26 | 27 | `src/assets/scss` 資料夾存放需要用到的樣式 28 | 29 | `src/assets/image` 資料夾存放需要用到的圖片 30 | 31 | `src/components` 資料夾存放共用的元件 32 | 33 | ### views 34 | 35 | 用來存放要作為頁面的檔案 36 | 37 | - 這裏為了區別前後台 另外添加了 `backend` `frontend` 兩個資料夾 38 | 39 | ### public 40 | 41 | 裡面存放不需要被編譯的檔案 以及最主要的 `index.html` 42 | 43 | - `index.html` 例外 會被編譯 44 | 45 | ### 其他自行創建的檔案 46 | 47 | - `.env` 用來設定環境變量 48 | 49 | - `webpack.config.js` 用來設定路徑 在該檔案中設定用 `@` 取代 `src` 資料夾路徑 50 | 51 | - `vue.config.js` 用來設定生產環境模式下的 `gh-page` 引用路徑 52 | 53 | - `.editorconfig` 加入 ESLint 後自動創建的檔案 54 | 55 | - `jsconfig.json` 手動設定 ESLint 56 | 57 | ## 一些比較重要的代碼詳解 58 | 59 | ### 1. 路由守衛 60 | 61 | vue-router 中提供了一個路由守衛的功能,我們可以藉由它來阻止路由跳轉,比如某些頁面需要登入後才能訪問,就可以藉由路由守衛判斷登入狀態再進行頁面訪問。 62 | 63 | ```js 64 | 65 | router.beforeEach((to, from, next) => { 66 | // 這裏更改網頁的 title 67 | if (to.meta.title) { 68 | document.title = to.meta.title 69 | } 70 | // 這裏檢查是否登入 71 | if(to.meta.requiresAuth) { 72 | const api = process.env.APIPATH + "api/user/check" 73 | axios.post(api).then((res) => { 74 | if(!res.data.success) { 75 | next({ path: '/login' }) 76 | } else { 77 | next() 78 | } 79 | }) 80 | } else { 81 | next() 82 | } 83 | }) 84 | 85 | ``` 86 | 87 | ### 2. 子路由使用方式 88 | 89 | - 通過在需要路由守衛的元件中添加上 `meta: { requiresAuth: true },` 後路由守衛就會幫忙判定登入狀態 90 | 91 | - 通過在路由對象中添加 `children:[]` 就可以增加子路由 子路由會生成在父組件中放有 `` 標籤的位置 92 | 93 | - 在路由中可以添加上 `{path: '*',redirect: '/'}` 即可讓所有沒被定義的路徑都會重定向到首頁 94 | 95 | - 在路由對象中添加 `meta: { title: '拼圖迷 - 進口拼圖專賣店'}` 可以更改`index.html` 的 `title` 96 | 97 | - `vue-cli v3` 以上的版本可以通過新的方法引入 `.vue` 檔 代碼如下: 98 | 99 | ```js 100 | 101 | component: () => import('../views/frontend/About.vue') 102 | 103 | ``` 104 | 105 | ### 3. vue-loading 用法 106 | 107 | 在代碼的最上面加入以下內容後即可通過 vue 中 data 下的 `isLoading` 資料狀態 來切換是否為載入中 108 | 109 | * 其樣式可到 `https://loading.io/` 中抓取使用 110 | 111 | ### 4. 切換與判斷路由 112 | 113 | * 可以在標籤中直接通過 `@click="$router.push('/')` 跳轉路由到 '/' 114 | 115 | * 可以使用 `this.$route.path!=='/'` 判斷路由是否為 '/' 116 | 117 | * 可以使用 `this.$route.fullPath` 獲取當前路由的完整路徑 118 | 119 | * 假設使用同個路由不同參數要切換時可以通過 `vm.$router.push("/products/" + category).catch((err) => err);` 來阻止報錯與頁面不刷新的問題 120 | 121 | ### 5. style 相關 122 | 123 | * 在元件中的 style 標籤上添加 `scoped` 就可以讓該元件的樣式僅在該元件中被使用 124 | 125 | * 可以通過在元件中的 style 標籤上添加 `lang="scss"` 來改變 css 語言 126 | 127 | 合併使用代碼如下: 128 | 129 | ```html 130 | 131 | 134 | 135 | ``` 136 | 137 | ### 6. cookie 相關 138 | 139 | 該專案使用 cookie 保存了喜好項目與後台的登入狀態 140 | 141 | 主要代碼分別在 `Login.vue` `Dashboard.vue` `Like.vue` 142 | 143 | * 登入的部分 144 | 145 | 在 `Login.vue` 中保存了登入的 `token` 主要代碼如下: 146 | 147 | ```js 148 | 149 | this.$http.post(api, vm.user).then((res) => { 150 | if (res.data.success) { 151 | const token = res.data.token; 152 | const expired = res.data.expired; 153 | document.cookie = `hexToken=${token}; expires=${new Date(expired)};`; 154 | } 155 | }) 156 | 157 | ``` 158 | 159 | 接下來在 `Dashboard.vue` 中將登入成功後取得的 token 設定於每次 request 中的 header 參數 160 | 161 | 如下: 162 | 163 | ```js 164 | 165 | created() { 166 | // 在登入時我們將 cookie 存到 hexToken 中,這裏就是獲取 hexToken 的值 167 | const myCookie = document.cookie.replace(/(?:(?:^|.*;\s*)hexToken\s*=\s*([^;]*).*$)|^.*$/,"$1"); 168 | // 這句是主要代碼 將 hexToken 的值 設定到 axios 的請求頭中 169 | this.$http.defaults.headers.common.Authorization = myCookie; 170 | }, 171 | 172 | ``` 173 | 174 | * 喜好項目 175 | 176 | 在 `Product.vue` 中直接判斷 cookie 裡面是否含有產品的 id 如果有代表該產品是喜好項目 177 | 178 | 在 `Like.vue` 中把所有 cookie 分成鍵對值然後判斷是否有 `like` 這個鍵 再獲取它對應的值從而取得所有喜好項目的產品 id 179 | 180 | 取得後再將所有 id 渲染成一個個的產品顯示於喜好項目列表中 181 | 182 | 主要代碼如下: 183 | 184 | ```js 185 | 186 | let cookieObj = {}; 187 | let cookieAry = document.cookie.split(";"); 188 | let cookie; 189 | let arr; 190 | for (var i = 0, l = cookieAry.length; i < l; ++i) { 191 | cookie = cookieAry[i].trim(); 192 | cookie = cookie.split("="); 193 | // cookie[0] => key ; cookie[1] => value 194 | if (cookie[0] === "like") { 195 | arr = cookie[1]; 196 | // 如果有人添加過喜好項目又清空的話 arr 就會是空字符串 197 | // 所以這裡要判斷它的長度大於零確定 cookie 中有產品 id 198 | if (arr.length !== 0) { 199 | // 這裡就是渲染喜好項目的 methods 200 | vm.getLikes(arr); 201 | } 202 | } 203 | } 204 | 205 | ``` 206 | 207 | ### 7. event Bus 使用 208 | 209 | 主要代碼在 `messageAlert.vue` 中 我將該專案中通過 event bus 建立提示訊息的同時生成購物車的數量 210 | 211 | 如下: 212 | 213 | ```js 214 | 215 | vm.$bus.$on("message:push", (message, status = "warning") => { 216 | // 獲取購物車數量 217 | vm.getCart(); 218 | // 生成互動訊息提示窗 219 | vm.updateMessage(message, status); 220 | }); 221 | 222 | ``` 223 | 224 | ### 8. 添加購買品項時判斷是否重複 225 | 226 | 如果重複的產品想添加 就更新產品數量 不要讓購物車有兩個一樣的品項 227 | 228 | 實現方法為: 229 | 230 | 1. 用 `find` 方法判斷是否有重複的對象 231 | 232 | 2. 重複的對象提取出來 將其產品 id 數量記下 並刪除該對象 233 | 234 | 3. 當有重複的對象時 添加產品的 api 參數就傳入剛才記下的對象 235 | 236 | 主要代碼範例如下: 237 | 238 | ```js 239 | 240 | addCart(id, num = 1) { 241 | const vm = this; 242 | let rel = vm.cart.find((item) => { 243 | return item.product_id === id; 244 | }); 245 | let obj; 246 | if (rel) { 247 | obj = { product_id: rel.product_id, qty: num + rel.qty }; 248 | vm.delCart(rel.id); 249 | } else { 250 | obj = { product_id: id, qty: num }; 251 | } 252 | vm.isLoading = true; 253 | const api = `${process.env.VUE_APP_APIPATH}api/${process.env.VUE_APP_MYPATH}/cart`; 254 | vm.$http.post(api, {data: obj,}) 255 | .then((res) => { 256 | vm.isLoading = false; 257 | if (res.data.success) { 258 | vm.$bus.$emit("message:push", "購物車清單已更新", "success"); 259 | } else { 260 | vm.$bus.$emit("message:push", res.data.message, "danger"); 261 | } 262 | }); 263 | } 264 | 265 | ``` 266 | 267 | ## 9. 結帳前重新確認品項 268 | 269 | 在購物車頁面中可以修改產品購買數量與添加優惠券 270 | 271 | 在最後按下確認結帳時才將剛才修改的數量與優惠券重新傳送 272 | 273 | 整個過程會先刪除原本購物車的內容 再添加更新後的數量與優惠券 274 | 275 | ajax 跑完後會開啟 modal 讓用戶填寫資料 按下送出後才跳轉至結帳頁面 276 | 277 | 主要代碼如下: 278 | 279 | ```js 280 | 281 | goCheckout () { 282 | const vm = this 283 | // 判斷購物車內容是否更新過 284 | if (JSON.stringify(vm.cart) === JSON.stringify(vm.tempCart)) { 285 | $('#modal').modal('show') 286 | } else { 287 | vm.isLoading = true 288 | // 讓 tempCart 為更新後的購買內容 289 | vm.tempCart.carts = vm.cart.carts 290 | const api = `${process.env.VUE_APP_APIPATH}api/${process.env.VUE_APP_MYPATH}/cart` 291 | vm.$http.get(api).then((res) => { 292 | if (res.data.success) { 293 | // 重新獲取購買內容 294 | vm.cart = res.data.data 295 | let times = vm.cart.carts.length 296 | vm.cart.carts.forEach((item) => { 297 | times-- 298 | let code = '' 299 | // 判斷是否有折價券 300 | if (vm.cart.carts[0].coupon) { 301 | code = vm.cart.carts[0].coupon.code 302 | } 303 | const api = `${process.env.VUE_APP_APIPATH}api/${process.env.VUE_APP_MYPATH}/cart/${item.id}` 304 | // 刪除所有購買產品 305 | vm.$http.delete(api).then() 306 | if (times === 0) { 307 | const api = `${process.env.VUE_APP_APIPATH}api/${process.env.VUE_APP_MYPATH}/cart` 308 | let times = vm.tempCart.carts.length 309 | // 重新添加更新後的購買產品、數量 310 | vm.tempCart.carts.forEach((item) => { 311 | const obj = { product_id: item.product_id, qty: item.qty } 312 | vm.$http.post(api, { data: obj }).then((res) => { 313 | if (res.data.success) { 314 | times-- 315 | const api = `${process.env.VUE_APP_APIPATH}api/${process.env.VUE_APP_MYPATH}/cart` 316 | vm.$http.get(api).then((res) => { 317 | if (res.data.success) { 318 | vm.cart = res.data.data 319 | vm.tempCart = vm.cart 320 | // 判斷是否使用優惠券並添加優惠券 321 | if (code !== '') { 322 | vm.couponCode = code 323 | if (times === 0) { 324 | vm.addCoupon(false) 325 | vm.isLoading = false 326 | } 327 | } else { 328 | vm.getCart(true) 329 | } 330 | } 331 | }) 332 | } 333 | }) 334 | }) 335 | vm.$bus.$emit('message:push', '購物車清單已更新', 'dark') 336 | } 337 | }) 338 | } else { 339 | vm.$bus.$emit('message:push', res.data.message, 'danger') 340 | } 341 | }) 342 | } 343 | } 344 | 345 | ``` -------------------------------------------------------------------------------- /src/views/frontend/Like.vue: -------------------------------------------------------------------------------- 1 | 102 | 103 | 306 | -------------------------------------------------------------------------------- /src/views/frontend/Checkout.vue: -------------------------------------------------------------------------------- 1 | 247 | 248 | 328 | 329 | 340 | -------------------------------------------------------------------------------- /src/views/backend/AdminCoupons.vue: -------------------------------------------------------------------------------- 1 | 300 | 301 | 390 | -------------------------------------------------------------------------------- /src/views/frontend/Product.vue: -------------------------------------------------------------------------------- 1 | 152 | 153 | 380 | 381 | 416 | -------------------------------------------------------------------------------- /src/views/backend/AdminProducts.vue: -------------------------------------------------------------------------------- 1 | 379 | 380 | 492 | -------------------------------------------------------------------------------- /src/views/frontend/Home.vue: -------------------------------------------------------------------------------- 1 | 429 | 430 | 564 | -------------------------------------------------------------------------------- /src/views/backend/TestOrders.vue: -------------------------------------------------------------------------------- 1 | 399 | 400 | 584 | -------------------------------------------------------------------------------- /src/views/frontend/Cart.vue: -------------------------------------------------------------------------------- 1 | 558 | 824 | 825 | 854 | --------------------------------------------------------------------------------