我們現在要來做購物車專案的前端,使用框架是Vue.js。
打開VSCode,開啟終端機,輸入
bun create vue@latest
以下是建立專案時的設定
✔ Project name: … shopping_cart_frontend
✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit testing? … No
✔ Add an End-to-End Testing Solution? … No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … No
✔ Add Vue DevTools 7 extension for debugging? (experimental) … No
安裝需要的套件後,啟動專案
cd shopping_cart_frontend
bun i
bun dev
前往http://localhost:5173/,可以看到寫著You did it!的網頁,確認專案成功建立。
刪除asset、views資料夾,清空components、router、stores裡的文件。
我們在React時做過同樣的事,Vue的流程也一樣
bun install -D tailwindcss postcss autoprefixer
bunx tailwindcss init -p
修改tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {}
},
plugins: []
}
新增src/style.css
@tailwind base;
@tailwind components;
@tailwind utilities;
在main.js啟用Tailwind CSS
import './style.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
//import router from './router'
const app = createApp(App)
app.use(createPinia())
//app.use(router)
app.mount('#app')
修改App.vue
<script setup></script>
<template>
<h1 class="text-3xl font-bold underline text-sky-500">Hello world!</h1>
</template>
啟動專案,可以看到藍色下劃線的Hello world!。
新增src/components/MainNavbar.vue,程式碼的取得來源是https://tailwindui.com/components/application-ui/navigation/navbars。
我們針對專案使用到的部分做些修改,修改圖示和選單內容,和按下購物車時會前往/cart。
<template>
<Disclosure as="nav" class="bg-gray-800" v-slot="{ open }">
<div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
<div class="relative flex h-16 items-center justify-between">
<div class="absolute inset-y-0 left-0 flex items-center sm:hidden">
<!-- Mobile menu button-->
<DisclosureButton
class="relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
>
<span class="absolute -inset-0.5" />
<span class="sr-only">Open main menu</span>
<Bars3Icon v-if="!open" class="block h-6 w-6" aria-hidden="true" />
<XMarkIcon v-else class="block h-6 w-6" aria-hidden="true" />
</DisclosureButton>
</div>
<div
class="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start"
>
<div class="hidden sm:ml-6 sm:block">
<div class="flex space-x-4">
<a
v-for="item in navigation"
:key="item.name"
:href="item.href"
:class="[
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'rounded-md px-3 py-2 text-sm font-medium',
]"
:aria-current="item.current ? 'page' : undefined"
>{{ item.name }}</a
>
</div>
</div>
</div>
<div
class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0"
>
<button
type="button"
class="relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
@click="goToCart"
>
<span class="absolute -inset-1.5" />
<span class="sr-only">Shopping Cart</span>
<ShoppingCartIcon class="h-6 w-6" aria-hidden="true"></ShoppingCartIcon>
</button>
<!-- Profile dropdown -->
<Menu as="div" class="relative ml-3">
<div>
<MenuButton
class="relative flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
>
<span class="absolute -inset-1.5" />
<span class="sr-only">Open user menu</span>
<UserIcon class="h-8 w-8 rounded-full bg-white"> </UserIcon>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<MenuItem v-slot="{ active }">
<a
href="/login"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
>Login</a
>
</MenuItem>
<MenuItem v-slot="{ active }">
<a
href="/signup"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
>Sign up</a
>
</MenuItem>
<MenuItem v-slot="{ active }">
<a
href="/signout"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
>Logout</a
>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
</div>
</div>
<DisclosurePanel class="sm:hidden">
<div class="space-y-1 px-2 pb-3 pt-2">
<DisclosureButton
v-for="item in navigation"
:key="item.name"
as="a"
:href="item.href"
:class="[
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'block rounded-md px-3 py-2 text-base font-medium',
]"
:aria-current="item.current ? 'page' : undefined"
>{{ item.name }}</DisclosureButton
>
</div>
</DisclosurePanel>
</Disclosure>
</template>
<script setup>
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
} from "@headlessui/vue";
import {
Bars3Icon,
ShoppingCartIcon,
UserIcon,
XMarkIcon,
} from "@heroicons/vue/24/outline";
const navigation = [{ name: "Add Product", href: "/add", current: true }];
const goToCart = () => {
window.location.href = "/cart";
}
</script>
@click在按下時,會執行後面對應的script。
安裝使用到的套件
bun i @headlessui/vue @heroicons/vue
在App.vue中顯示MainNavbar
<script setup>
import MainNavbar from "./components/MainNavbar.vue";
</script>
<template>
<MainNavbar />
</template>
確認按下後URL的變化。
按下用戶頭像後,繼續按下出現的選項
上一段我們按下許多的按鈕,雖然URL有變化,但是頁面的內容都是相同的。
接下來,導入Vue Router進行路由的設定,根據URL顯示不同的結果。
修改main.js啟用Vue Router
import './style.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
建立src/components/RegisterForm.vue
<template>
RegisterForm
</template>
在router/index.js增加註冊頁面的路由。
import RegisterForm from "@/components/RegisterForm.vue"
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/signup',
name: 'signup',
component: RegisterForm
}
]
})
export default router
App.vue添加,根據Vue Router的設定改變顯示的內容。
<script setup>
import MainNavbar from "./components/MainNavbar.vue";
</script>
<template>
<MainNavbar />
<RouterView />
</template>
我們在專案點擊Sign up,在導航列的下方可以看到RegisterForm的文字,代表Vue Router的設定沒有錯誤。
修改MainNavbar.vue,導入Vue Router,把切換頁面的href換成@click,按下時執行router.push
<template>
<Disclosure as="nav" class="bg-gray-800" v-slot="{ open }">
<div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
<div class="relative flex h-16 items-center justify-between">
<div class="absolute inset-y-0 left-0 flex items-center sm:hidden">
<!-- Mobile menu button-->
<DisclosureButton
class="relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
>
<span class="absolute -inset-0.5" />
<span class="sr-only">Open main menu</span>
<Bars3Icon v-if="!open" class="block h-6 w-6" aria-hidden="true" />
<XMarkIcon v-else class="block h-6 w-6" aria-hidden="true" />
</DisclosureButton>
</div>
<div
class="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start"
>
<div class="hidden sm:ml-6 sm:block">
<div class="flex space-x-4">
<a
v-for="item in navigation"
:key="item.name"
@click="router.push(item.href)"
:class="[
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'rounded-md px-3 py-2 text-sm font-medium',
]"
:aria-current="item.current ? 'page' : undefined"
>{{ item.name }}</a
>
</div>
</div>
</div>
<div
class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0"
>
<button
type="button"
class="relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
@click="router.push('/cart')"
>
<span class="absolute -inset-1.5" />
<span class="sr-only">Shopping Cart</span>
<ShoppingCartIcon class="h-6 w-6" aria-hidden="true"></ShoppingCartIcon>
</button>
<!-- Profile dropdown -->
<Menu as="div" class="relative ml-3">
<div>
<MenuButton
class="relative flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
>
<span class="absolute -inset-1.5" />
<span class="sr-only">Open user menu</span>
<UserIcon class="h-8 w-8 rounded-full bg-white"> </UserIcon>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<MenuItem v-slot="{ active }">
<a
@click="router.push('/login')"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
>Login</a
>
</MenuItem>
<MenuItem v-slot="{ active }">
<a
@click="router.push('/signup')"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
>Sign up</a
>
</MenuItem>
<MenuItem v-slot="{ active }">
<a
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
@click="logout"
>Logout</a
>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
</div>
</div>
<DisclosurePanel class="sm:hidden">
<div class="space-y-1 px-2 pb-3 pt-2">
<DisclosureButton
v-for="item in navigation"
:key="item.name"
@click="router.push(item.href)"
:class="[
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'block rounded-md px-3 py-2 text-base font-medium',
]"
:aria-current="item.current ? 'page' : undefined"
>{{ item.name }}</DisclosureButton
>
</div>
</DisclosurePanel>
</Disclosure>
</template>
<script setup>
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
} from "@headlessui/vue";
import {
Bars3Icon,
ShoppingCartIcon,
UserIcon,
XMarkIcon,
} from "@heroicons/vue/24/outline";
import router from "@/router";
const navigation = [{ name: "Home", href: "/", current: false },{ name: "Add Product", href: "/add", current: false }];
const logout = () => {
router.push("/");
};
</script>
繼續寫RegisterForm.vue的程式碼
<template>
<div className="flex justify-center min-h-screen items-center bg-gray-100">
<form
method="post"
role="form"
className="bg-white p-6 rounded-lg shadow-md w-full max-w-md"
@submit.prevent="handleSubmit"
>
<div className="mb-4">
<label className="text-gray-700 font-bold mb-2"> Email </label>
<input
placeholder="Enter email address"
type="text"
className="shadow border rounded w-full py-2 px-3 text-gray-700"
name="email"
autocomplete="current-email"
v-model="email"
/>
</div>
<div className="mb-4">
<label className="text-gray-700 font-bold mb-2"> Password </label>
<input
placeholder="Enter password"
type="password"
className="shadow border rounded w-full py-2 px-3 text-gray-700"
name="password"
autocomplete="current-password"
v-model="password"
/>
</div>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Register
</button>
</form>
</div>
</template>
<script>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
export default {
setup() {
const router = useRouter();
//取得表單的email和password
const email = ref('');
const password = ref('');
//處理按下表單送出後的事件
const handleSubmit = () => {
//顯示email以及password
console.log(email.value);
console.log(password.value);
router.push('/');
};
return {
email,
password,
handleSubmit,
};
},
};
</script>
@submit代表按下送出後會執行的內容,後面加上的.prevent,是為了阻止HTML的方式送出表單,讓Vue處理表單。
v-model會將這個欄位的內容綁定到對應名稱的const。
在導入後端前,我們先測試一下表單送出的功能。
填寫email和password欄位,按下F12切換到Console,點擊Register送出。
就能在Console中看到剛填入的email、password數值。
在SecurityConfig.java取消註解CORS的部分
.cors(cors -> cors.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList(
"http://localhost:5173"
));
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
config.setAllowCredentials(true);
config.setExposedHeaders(Arrays.asList("Authorization"));
config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
config.setMaxAge(3600L);
return config;
}
}))
這樣Vue才能和後端溝通。
安裝axios
bun i axios
我們在RegisterForm.vue修改handleSubmit,讓它傳送註冊請求
const handleSubmit = async () => {
try {
//傳送註冊請求
const response = await axios.post('http://localhost:8080/auth/signup', {
email: email.value,
password: password.value,
});
//顯示註冊成功訊息
console.log(response.data);
router.push('/');
} catch (error) {
alert("Email already exists");
console.log(error);
}
};
啟動後端,再次進行註冊,成功的話可以在Console看到以下訊息
{jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3MjMyNjk0NTYsImV4c…9tIn0.wxhXF6khkLpLwI2iKzQ4SLq5nyKNWn9SufnZ8zvJYkI'
, message: 'Signup Success'}
註冊成功後,應該會登入,但因為我們沒有做相關的處理,所以現在我們使用Pinia來管理登入的狀態。
修改main.js,啟用Pinia
import './style.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
新增src/stores/auth.js,用來管理token的狀態。
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
token: null, //token的初始值為null
}),
getters: {
isAuthenticated: (state) => !!state.token, //如果token不是null就回傳true,代表已登入
},
actions: {
setToken(token) {
this.token = token; //將token設定為傳入的token
},
clearToken() {
this.token = null; //將token清空
},
},
});
對MainNavbar.vue做修改,在未登入時顯示Login、Sign up,其餘的Home、Add Product、購物車按鈕都隱藏。
登入後,顯示Home、Add Product、購物車,按下用戶頭像後,改為顯示Logout
<template>
<Disclosure as="nav" class="bg-gray-800" v-slot="{ open }">
<div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
<div class="relative flex h-16 items-center justify-between">
<div class="absolute inset-y-0 left-0 flex items-center sm:hidden">
<!-- Mobile menu button-->
<DisclosureButton
class="relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
>
<span class="absolute -inset-0.5" />
<span class="sr-only">Open main menu</span>
<Bars3Icon v-if="!open" class="block h-6 w-6" aria-hidden="true" />
<XMarkIcon v-else class="block h-6 w-6" aria-hidden="true" />
</DisclosureButton>
</div>
<div
class="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start"
>
<div class="hidden sm:ml-6 sm:block">
<div class="flex space-x-4" v-if="isAuthenticated">
<a
v-for="item in navigation"
:key="item.name"
@click="router.push(item.href)"
:class="[
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'rounded-md px-3 py-2 text-sm font-medium',
]"
:aria-current="item.current ? 'page' : undefined"
>{{ item.name }}</a
>
</div>
</div>
</div>
<div
class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0"
>
<button
v-if="isAuthenticated"
type="button"
class="relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
@click="router.push('/cart')"
>
<span class="absolute -inset-1.5" />
<span class="sr-only">Shopping Cart</span>
<ShoppingCartIcon class="h-6 w-6" aria-hidden="true"></ShoppingCartIcon>
</button>
<!-- Profile dropdown -->
<Menu as="div" class="relative ml-3">
<div>
<MenuButton
class="relative flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
>
<span class="absolute -inset-1.5" />
<span class="sr-only">Open user menu</span>
<UserIcon class="h-8 w-8 rounded-full bg-white"> </UserIcon>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<MenuItem v-slot="{ active }" v-if="!isAuthenticated">
<a
@click="router.push('/login')"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
>Login</a
>
</MenuItem>
<MenuItem v-slot="{ active }" v-if="!isAuthenticated">
<a
@click="router.push('/signup')"
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
>Sign up</a
>
</MenuItem>
<MenuItem v-slot="{ active }" v-if="isAuthenticated">
<a
:class="[
active ? 'bg-gray-100' : '',
'block px-4 py-2 text-sm text-gray-700',
]"
@click="logout"
>Logout</a
>
</MenuItem>
</MenuItems>
</transition>
</Menu>
</div>
</div>
</div>
<DisclosurePanel class="sm:hidden">
<div class="space-y-1 px-2 pb-3 pt-2" v-if="isAuthenticated">
<DisclosureButton
v-for="item in navigation"
:key="item.name"
@click="router.push(item.href)"
:class="[
item.current
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
'block rounded-md px-3 py-2 text-base font-medium',
]"
:aria-current="item.current ? 'page' : undefined"
>{{ item.name }}</DisclosureButton
>
</div>
</DisclosurePanel>
</Disclosure>
</template>
當v-if的結果是true會顯示,反之false則隱藏。
在MainNavbar.vue的script的部分添加
import { useAuthStore } from '@/stores/auth';
import { computed } from 'vue';
const authStore = useAuthStore();
const logout = () => {
authStore.clearToken();//使用clearToken清除儲存的token
window.location.href = "/";
};
const isAuthenticated = computed(() => authStore.isAuthenticated);//使用computed來判斷是否有token
現在觀察前端網頁,點用戶頭像會只剩Login和Sign up,Logout消失了。
不過我們還沒有把登入狀態放到auth.js中,所以只會顯示Login、Sign up。
我們在RegisterForm.vue,導入auth.js管理token狀態,在註冊後登入系統。
import { useAuthStore } from '@/stores/auth';
export default {
setup() {
// ...
//使用pinia
const authStore = useAuthStore();
//...
const handleSubmit = async () => {
try {
//...
//設定token
authStore.setToken(response.data.jwt);
router.push('/');
} catch (error) {
//...
}
};
//...
},
};
註冊完成後,點擊用戶頭像,因為我們有登入,所以只會顯示Logout。
只要按下Logout,就能回到未登入的狀態。
複製RegisterForm.vue改名為LoginForm.vue
在router/index.js添加LoginForm的路由
routes: [
{
path: '/signup',
name: 'signup',
component: RegisterForm
},
{
path: '/login',
name: 'login',
component: LoginForm
}
]
在前端測試登入頁面,只要email和密碼正確就能登入。