PrimeVue 使用教學手冊
📋 目錄
第一章:基礎入門
1.1 PrimeVue 簡介
PrimeVue 是一個功能豐富的 Vue.js UI 元件庫,提供了超過 90+ 個高品質的元件,專為現代 Web 應用程式開發而設計。
🌟 核心特色
- 豐富的元件庫:提供完整的 UI 元件,從基本按鈕到複雜的資料表格
- 主題系統:內建多種主題,支援自訂樣式
- 響應式設計:完全支援行動裝置和桌面版本
- 無障礙支援:符合 WCAG 2.1 標準
- TypeScript 支援:完整的型別定義
- 效能優化:輕量化設計,支援樹搖 (Tree Shaking)
📊 PrimeVue 生態系統
💼 適用場景
- 企業級應用:ERP、CRM、後台管理系統
- 電商平台:商品展示、購物車、訂單管理
- 資料儀表板:圖表展示、資料分析介面
- 內容管理系統:部落格、新聞網站
1.2 PrimeVue 與 Vue.js 的關係
🔗 版本對應關係
PrimeVue 版本 | Vue.js 版本 | 發布時間 | 主要特色 |
---|---|---|---|
4.x | Vue 3.4+ | 2024 | 新架構、效能提升 |
3.x | Vue 3.0+ | 2021 | Composition API 支援 |
2.x | Vue 2.6+ | 2020 | Options API (已停止維護) |
🏗️ 架構整合
⚡ Vue 3 Composition API 優勢
// Vue 3 + PrimeVue 範例
import { ref, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
export default {
setup() {
const toast = useToast()
const items = ref([])
const filteredItems = computed(() => {
return items.value.filter(item => item.active)
})
const showSuccess = () => {
toast.add({
severity: 'success',
summary: '成功',
detail: '操作完成',
life: 3000
})
}
return {
items,
filteredItems,
showSuccess
}
}
}
1.3 安裝與設定
📦 安裝方式
方式一:使用 npm
# 安裝 PrimeVue 核心套件
npm install primevue
# 安裝圖示庫
npm install primeicons
# 安裝主題(可選)
npm install @primevue/themes
方式二:使用 yarn
yarn add primevue primeicons @primevue/themes
方式三:使用 pnpm
pnpm add primevue primeicons @primevue/themes
⚙️ Vue 3 + Vite 專案設定
1. 建立 Vite 專案
# 建立新專案
npm create vue@latest my-primevue-app
# 進入專案目錄
cd my-primevue-app
# 安裝依賴
npm install
# 安裝 PrimeVue
npm install primevue primeicons
2. 主要設定檔 (main.js)
import { createApp } from 'vue'
import App from './App.vue'
// PrimeVue 核心
import PrimeVue from 'primevue/config'
// PrimeVue 樣式
import 'primevue/resources/themes/aura-light-green/theme.css'
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css'
// 建立應用程式實例
const app = createApp(App)
// 使用 PrimeVue
app.use(PrimeVue)
// 掛載應用程式
app.mount('#app')
3. 元件註冊方式
全域註冊 (推薦用於小型專案)
// main.js
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
app.component('Button', Button)
app.component('InputText', InputText)
app.component('DataTable', DataTable)
app.component('Column', Column)
區域註冊 (推薦用於大型專案)
// MyComponent.vue
<script>
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
export default {
components: {
Button,
InputText
}
}
</script>
🎨 主題設定
預設主題選擇
// main.js - 選擇不同主題
import 'primevue/resources/themes/aura-light-blue/theme.css' // Aura 淺色藍色
import 'primevue/resources/themes/aura-dark-green/theme.css' // Aura 深色綠色
import 'primevue/resources/themes/lara-light-indigo/theme.css' // Lara 淺色靛藍
import 'primevue/resources/themes/material-design-light/theme.css' // Material Design
動態主題切換
// utils/theme.js
export const switchTheme = (themeName) => {
const linkElement = document.getElementById('theme-link')
linkElement.href = `/themes/${themeName}/theme.css`
}
// 使用範例
switchTheme('aura-dark-green')
1.4 建立第一個 PrimeVue 專案
🚀 完整專案建立流程
步驟 1:初始化專案
# 建立 Vue 3 專案
npm create vue@latest primevue-demo
# 選擇以下選項
✔ Add TypeScript? … No / Yes (建議選 Yes)
✔ Add JSX Support? … No / Yes
✔ Add Vue Router for Single Page Application development? … No / Yes (建議選 Yes)
✔ Add Pinia for state management? … No / Yes (建議選 Yes)
✔ Add Vitest for Unit testing? … No / Yes
✔ Add an End-to-End Testing Solution? … No / Yes
✔ Add ESLint for code quality? … No / Yes (建議選 Yes)
cd primevue-demo
npm install
步驟 2:安裝 PrimeVue
npm install primevue primeicons @primevue/themes
步驟 3:專案結構
primevue-demo/
├── public/
├── src/
│ ├── assets/
│ ├── components/
│ │ └── common/ # 共用元件
│ ├── views/ # 頁面元件
│ ├── router/
│ ├── stores/ # Pinia stores
│ ├── utils/
│ │ └── primevue.js # PrimeVue 設定
│ ├── styles/
│ │ └── global.css # 全域樣式
│ ├── App.vue
│ └── main.js
├── index.html
├── package.json
└── vite.config.js
步驟 4:PrimeVue 設定檔
// src/utils/primevue.js
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
// 常用元件
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Card from 'primevue/card'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
export const setupPrimeVue = (app) => {
// 安裝 PrimeVue
app.use(PrimeVue)
app.use(ToastService)
app.use(ConfirmationService)
// 註冊全域元件
app.component('Button', Button)
app.component('InputText', InputText)
app.component('Card', Card)
app.component('Toast', Toast)
app.component('ConfirmDialog', ConfirmDialog)
}
步驟 5:更新 main.js
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// PrimeVue 設定
import { setupPrimeVue } from './utils/primevue'
// PrimeVue 樣式
import 'primevue/resources/themes/aura-light-green/theme.css'
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css'
// 全域樣式
import './styles/global.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// 設定 PrimeVue
setupPrimeVue(app)
app.mount('#app')
1.5 Hello World 範例
🎯 基本 Hello World
<!-- src/views/HelloWorld.vue -->
<template>
<div class="hello-world">
<Card>
<template #title>
🎉 歡迎使用 PrimeVue
</template>
<template #content>
<div class="content-section">
<h3>Hello, {{ userName }}!</h3>
<p>這是您的第一個 PrimeVue 應用程式</p>
<div class="input-group">
<label for="name">輸入您的姓名:</label>
<InputText
id="name"
v-model="userName"
placeholder="請輸入姓名"
class="w-full"
/>
</div>
<div class="button-group">
<Button
label="打招呼"
icon="pi pi-user"
@click="sayHello"
class="p-button-success"
/>
<Button
label="重置"
icon="pi pi-refresh"
@click="reset"
class="p-button-secondary"
/>
</div>
</div>
</template>
</Card>
<!-- Toast 通知 -->
<Toast />
</div>
</template>
<script>
import { ref } from 'vue'
import { useToast } from 'primevue/usetoast'
export default {
name: 'HelloWorld',
setup() {
const toast = useToast()
const userName = ref('World')
const sayHello = () => {
toast.add({
severity: 'success',
summary: '問候',
detail: `Hello, ${userName.value}! 歡迎使用 PrimeVue!`,
life: 3000
})
}
const reset = () => {
userName.value = 'World'
toast.add({
severity: 'info',
summary: '重置',
detail: '已重置為預設值',
life: 2000
})
}
return {
userName,
sayHello,
reset
}
}
}
</script>
<style scoped>
.hello-world {
max-width: 600px;
margin: 2rem auto;
padding: 1rem;
}
.content-section {
text-align: center;
}
.input-group {
margin: 1.5rem 0;
}
.input-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.button-group {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
@media (max-width: 768px) {
.button-group {
flex-direction: column;
align-items: center;
}
}
</style>
🎨 進階互動範例
<!-- src/views/InteractiveDemo.vue -->
<template>
<div class="interactive-demo">
<div class="demo-header">
<h2>🚀 PrimeVue 互動示範</h2>
<p>體驗 PrimeVue 元件的強大功能</p>
</div>
<div class="demo-grid">
<!-- 計數器卡片 -->
<Card class="demo-card">
<template #title>
<i class="pi pi-calculator"></i> 計數器
</template>
<template #content>
<div class="counter-section">
<div class="counter-display">
<span class="counter-value">{{ counter }}</span>
</div>
<div class="counter-controls">
<Button
icon="pi pi-minus"
@click="decrement"
:disabled="counter <= 0"
class="p-button-outlined p-button-danger"
/>
<Button
icon="pi pi-plus"
@click="increment"
class="p-button-outlined p-button-success"
/>
</div>
</div>
</template>
</Card>
<!-- 資料表格卡片 -->
<Card class="demo-card">
<template #title>
<i class="pi pi-table"></i> 使用者列表
</template>
<template #content>
<DataTable
:value="users"
:paginator="true"
:rows="3"
responsiveLayout="scroll"
>
<Column field="name" header="姓名"></Column>
<Column field="email" header="Email"></Column>
<Column field="role" header="角色"></Column>
</DataTable>
</template>
</Card>
</div>
<!-- 功能按鈕 -->
<div class="action-buttons">
<Button
label="顯示資訊"
icon="pi pi-info-circle"
@click="showInfo"
class="p-button-info"
/>
<Button
label="確認操作"
icon="pi pi-check"
@click="showConfirm"
class="p-button-warning"
/>
</div>
<!-- 全域元件 -->
<Toast />
<ConfirmDialog />
</div>
</template>
<script>
import { ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
export default {
name: 'InteractiveDemo',
components: {
DataTable,
Column
},
setup() {
const toast = useToast()
const confirm = useConfirm()
const counter = ref(0)
// 範例資料
const users = ref([
{ name: '張小明', email: 'zhang@example.com', role: '開發者' },
{ name: '李小華', email: 'li@example.com', role: '設計師' },
{ name: '王小美', email: 'wang@example.com', role: '專案經理' },
{ name: '陳小強', email: 'chen@example.com', role: '測試工程師' },
{ name: '林小雯', email: 'lin@example.com', role: 'UI/UX 設計師' }
])
const increment = () => {
counter.value++
toast.add({
severity: 'success',
summary: '增加',
detail: `計數器現在是 ${counter.value}`,
life: 1000
})
}
const decrement = () => {
if (counter.value > 0) {
counter.value--
toast.add({
severity: 'warn',
summary: '減少',
detail: `計數器現在是 ${counter.value}`,
life: 1000
})
}
}
const showInfo = () => {
toast.add({
severity: 'info',
summary: '資訊',
detail: '這是一個 PrimeVue 示範應用程式,展示了多種常用元件的使用方式。',
life: 4000
})
}
const showConfirm = () => {
confirm.require({
message: '您確定要執行這個操作嗎?',
header: '確認操作',
icon: 'pi pi-exclamation-triangle',
accept: () => {
toast.add({
severity: 'success',
summary: '確認',
detail: '操作已確認執行',
life: 3000
})
},
reject: () => {
toast.add({
severity: 'error',
summary: '取消',
detail: '操作已取消',
life: 3000
})
}
})
}
return {
counter,
users,
increment,
decrement,
showInfo,
showConfirm
}
}
}
</script>
<style scoped>
.interactive-demo {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.demo-header {
text-align: center;
margin-bottom: 2rem;
}
.demo-header h2 {
color: #495057;
margin-bottom: 0.5rem;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.demo-card {
height: fit-content;
}
.counter-section {
text-align: center;
}
.counter-display {
margin-bottom: 1rem;
}
.counter-value {
font-size: 3rem;
font-weight: bold;
color: #007ad9;
}
.counter-controls {
display: flex;
gap: 1rem;
justify-content: center;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.demo-grid {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
align-items: center;
}
}
</style>
✅ 第一章檢查清單
基礎設定完成檢查:
- ✅ 成功安裝 PrimeVue、PrimeIcons
- ✅ 正確設定 main.js 並匯入必要樣式
- ✅ 選擇並套用合適的主題
- ✅ 建立基本的專案結構
- ✅ 能夠成功執行 Hello World 範例
- ✅ Toast 通知功能正常運作
- ✅ 基本元件 (Button, InputText, Card) 正常顯示
常見問題排除:
問題 | 可能原因 | 解決方法 |
---|---|---|
樣式未載入 | CSS 匯入順序錯誤 | 確保主題 CSS 在最前面 |
元件未顯示 | 忘記註冊元件 | 檢查全域或區域註冊 |
圖示未顯示 | PrimeIcons 未安裝 | npm install primeicons |
Toast 無法使用 | 未安裝 ToastService | 在 main.js 中加入 app.use(ToastService) |
🎯 實務注意事項
- 效能考量:大型專案建議使用區域註冊避免 bundle 過大
- 主題一致性:團隊開發時統一使用相同主題
- 響應式設計:確保在不同裝置上的顯示效果
- 無障礙支援:善用 PrimeVue 內建的 ARIA 屬性
第二章:核心元件介紹
2.1 按鈕(Button)與圖示(IconButton)
🔘 Button 元件基礎
Button 是最常用的互動元件,PrimeVue 提供了豐富的按鈕樣式和功能。
基本用法
<template>
<div class="button-examples">
<!-- 基本按鈕 -->
<Button label="基本按鈕" />
<!-- 帶圖示的按鈕 -->
<Button label="儲存" icon="pi pi-save" />
<!-- 圖示在右側 -->
<Button label="下載" icon="pi pi-download" iconPos="right" />
<!-- 純圖示按鈕 -->
<Button icon="pi pi-search" aria-label="搜尋" />
<!-- 不同大小 -->
<Button label="小按鈕" size="small" />
<Button label="一般按鈕" />
<Button label="大按鈕" size="large" />
</div>
</template>
按鈕樣式類型
<template>
<div class="button-styles">
<!-- 主要按鈕 -->
<Button label="主要" class="p-button-primary" />
<!-- 次要按鈕 -->
<Button label="次要" class="p-button-secondary" />
<!-- 成功按鈕 -->
<Button label="成功" class="p-button-success" />
<!-- 資訊按鈕 -->
<Button label="資訊" class="p-button-info" />
<!-- 警告按鈕 -->
<Button label="警告" class="p-button-warning" />
<!-- 危險按鈕 -->
<Button label="危險" class="p-button-danger" />
<!-- 幫助按鈕 -->
<Button label="幫助" class="p-button-help" />
<!-- 外框按鈕 -->
<Button label="外框" class="p-button-outlined" />
<!-- 文字按鈕 -->
<Button label="文字" class="p-button-text" />
<!-- 連結按鈕 -->
<Button label="連結" class="p-button-link" />
</div>
</template>
📏 Button 屬性 (Props)
屬性 | 類型 | 預設值 | 說明 |
---|---|---|---|
label | string | null | 按鈕文字 |
icon | string | null | 圖示 CSS 類別 |
iconPos | string | ’left' | 圖示位置:’left’, ‘right’, ’top’, ‘bottom’ |
size | string | null | 按鈕大小:‘small’, ’large’ |
disabled | boolean | false | 是否禁用 |
loading | boolean | false | 載入狀態 |
loadingIcon | string | ‘pi pi-spinner pi-spin’ | 載入圖示 |
outlined | boolean | false | 外框樣式 |
text | boolean | false | 文字樣式 |
raised | boolean | false | 立體效果 |
rounded | boolean | false | 圓角樣式 |
severity | string | null | 顏色主題:‘secondary’, ‘success’, ‘info’, ‘warning’, ‘help’, ‘danger’ |
⚡ Button 事件
事件 | 參數 | 說明 |
---|---|---|
click | event | 點擊事件 |
focus | event | 獲得焦點 |
blur | event | 失去焦點 |
🎯 實務按鈕範例
<template>
<div class="practical-buttons">
<Card>
<template #title>實務按鈕應用</template>
<template #content>
<!-- 表單操作按鈕 -->
<div class="form-actions">
<h4>表單操作</h4>
<div class="button-group">
<Button
label="儲存"
icon="pi pi-save"
:loading="saving"
@click="save"
class="p-button-success"
/>
<Button
label="取消"
icon="pi pi-times"
@click="cancel"
class="p-button-secondary p-button-outlined"
/>
<Button
label="重置"
icon="pi pi-refresh"
@click="reset"
class="p-button-warning p-button-outlined"
/>
</div>
</div>
<!-- 資料操作按鈕 -->
<div class="data-actions">
<h4>資料操作</h4>
<div class="button-group">
<Button
label="新增"
icon="pi pi-plus"
@click="add"
class="p-button-success"
/>
<Button
label="編輯"
icon="pi pi-pencil"
@click="edit"
:disabled="!selectedItem"
class="p-button-info"
/>
<Button
label="刪除"
icon="pi pi-trash"
@click="confirmDelete"
:disabled="!selectedItem"
class="p-button-danger"
/>
</div>
</div>
<!-- 載入狀態按鈕 -->
<div class="loading-actions">
<h4>載入狀態</h4>
<div class="button-group">
<Button
label="提交資料"
icon="pi pi-upload"
:loading="uploading"
@click="uploadData"
class="p-button-primary"
/>
<Button
label="下載報表"
icon="pi pi-download"
:loading="downloading"
@click="downloadReport"
class="p-button-info"
/>
</div>
</div>
<!-- 不同尺寸按鈕 -->
<div class="size-demo">
<h4>按鈕尺寸</h4>
<div class="button-group">
<Button label="小" size="small" />
<Button label="一般" />
<Button label="大" size="large" />
</div>
</div>
<!-- 圖示按鈕組 -->
<div class="icon-buttons">
<h4>圖示按鈕</h4>
<div class="button-group">
<Button
icon="pi pi-home"
aria-label="首頁"
class="p-button-rounded"
@click="goHome"
/>
<Button
icon="pi pi-search"
aria-label="搜尋"
class="p-button-rounded p-button-outlined"
@click="search"
/>
<Button
icon="pi pi-user"
aria-label="使用者"
class="p-button-rounded p-button-info"
@click="showProfile"
/>
<Button
icon="pi pi-cog"
aria-label="設定"
class="p-button-rounded p-button-secondary"
@click="showSettings"
/>
</div>
</div>
</template>
</Card>
<Toast />
<ConfirmDialog />
</div>
</template>
<script>
import { ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
export default {
name: 'PracticalButtons',
setup() {
const toast = useToast()
const confirm = useConfirm()
// 狀態管理
const saving = ref(false)
const uploading = ref(false)
const downloading = ref(false)
const selectedItem = ref(null)
// 表單操作
const save = async () => {
saving.value = true
try {
// 模擬 API 呼叫
await new Promise(resolve => setTimeout(resolve, 2000))
toast.add({
severity: 'success',
summary: '儲存成功',
detail: '資料已成功儲存',
life: 3000
})
} finally {
saving.value = false
}
}
const cancel = () => {
toast.add({
severity: 'info',
summary: '已取消',
detail: '操作已取消',
life: 2000
})
}
const reset = () => {
confirm.require({
message: '確定要重置所有資料嗎?此操作無法復原。',
header: '確認重置',
icon: 'pi pi-exclamation-triangle',
accept: () => {
toast.add({
severity: 'warn',
summary: '已重置',
detail: '所有資料已重置',
life: 3000
})
}
})
}
// 資料操作
const add = () => {
toast.add({
severity: 'success',
summary: '新增模式',
detail: '進入新增資料模式',
life: 2000
})
}
const edit = () => {
if (selectedItem.value) {
toast.add({
severity: 'info',
summary: '編輯模式',
detail: '進入編輯資料模式',
life: 2000
})
}
}
const confirmDelete = () => {
confirm.require({
message: '確定要刪除此項目嗎?',
header: '確認刪除',
icon: 'pi pi-exclamation-triangle',
accept: () => {
toast.add({
severity: 'error',
summary: '已刪除',
detail: '項目已刪除',
life: 3000
})
selectedItem.value = null
}
})
}
// 載入操作
const uploadData = async () => {
uploading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 3000))
toast.add({
severity: 'success',
summary: '上傳完成',
detail: '資料上傳成功',
life: 3000
})
} finally {
uploading.value = false
}
}
const downloadReport = async () => {
downloading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 2000))
toast.add({
severity: 'success',
summary: '下載完成',
detail: '報表下載成功',
life: 3000
})
} finally {
downloading.value = false
}
}
// 圖示按鈕操作
const goHome = () => {
toast.add({ severity: 'info', summary: '導航', detail: '回到首頁', life: 2000 })
}
const search = () => {
toast.add({ severity: 'info', summary: '搜尋', detail: '開啟搜尋功能', life: 2000 })
}
const showProfile = () => {
toast.add({ severity: 'info', summary: '使用者', detail: '顯示使用者資料', life: 2000 })
}
const showSettings = () => {
toast.add({ severity: 'info', summary: '設定', detail: '開啟系統設定', life: 2000 })
}
return {
saving,
uploading,
downloading,
selectedItem,
save,
cancel,
reset,
add,
edit,
confirmDelete,
uploadData,
downloadReport,
goHome,
search,
showProfile,
showSettings
}
}
}
</script>
<style scoped>
.practical-buttons {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.form-actions,
.data-actions,
.loading-actions,
.size-demo,
.icon-buttons {
margin-bottom: 2rem;
padding: 1rem;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
h4 {
margin-bottom: 1rem;
color: #495057;
font-weight: 600;
}
@media (max-width: 768px) {
.button-group {
flex-direction: column;
}
}
</style>
🎨 自訂按鈕樣式
<template>
<div class="custom-buttons">
<!-- 使用 CSS 變數自訂 -->
<Button
label="自訂主色"
class="custom-primary"
/>
<!-- 使用 Tailwind CSS (如果有安裝) -->
<Button
label="Tailwind 樣式"
class="bg-purple-500 hover:bg-purple-600 text-white border-purple-500"
/>
<!-- 漸層按鈕 -->
<Button
label="漸層效果"
class="gradient-button"
/>
</div>
</template>
<style>
.custom-primary {
--p-button-primary-background: #6366f1;
--p-button-primary-border-color: #6366f1;
--p-button-primary-hover-background: #4f46e5;
--p-button-primary-hover-border-color: #4f46e5;
}
.gradient-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
}
.gradient-button:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
</style>
2.2 表單元件(InputText、Password、Dropdown、Checkbox、RadioButton、Calendar、Slider)
📝 InputText 輸入框
InputText 是最基本的文字輸入元件,支援各種輸入類型和驗證。
基本用法
<template>
<div class="input-examples">
<!-- 基本輸入框 -->
<div class="field">
<label for="username">使用者名稱</label>
<InputText id="username" v-model="username" placeholder="請輸入使用者名稱" />
</div>
<!-- 帶圖示的輸入框 -->
<div class="field">
<label for="email">Email</label>
<span class="p-input-icon-left">
<i class="pi pi-envelope"></i>
<InputText id="email" v-model="email" placeholder="請輸入 Email" />
</span>
</div>
<!-- 右側圖示 -->
<div class="field">
<label for="search">搜尋</label>
<span class="p-input-icon-right">
<InputText id="search" v-model="searchTerm" placeholder="搜尋..." />
<i class="pi pi-search"></i>
</span>
</div>
<!-- 不同尺寸 -->
<div class="field">
<label>不同尺寸</label>
<div class="size-group">
<InputText v-model="text1" placeholder="小" size="small" />
<InputText v-model="text2" placeholder="一般" />
<InputText v-model="text3" placeholder="大" size="large" />
</div>
</div>
<!-- 禁用狀態 -->
<div class="field">
<label for="disabled">禁用輸入框</label>
<InputText id="disabled" v-model="disabledText" disabled placeholder="此欄位已禁用" />
</div>
<!-- 驗證狀態 -->
<div class="field">
<label for="validated">驗證輸入框</label>
<InputText
id="validated"
v-model="validatedText"
:class="{ 'p-invalid': !isValid }"
placeholder="請輸入至少 3 個字元"
@blur="validateInput"
/>
<small v-if="!isValid" class="p-error">至少需要 3 個字元</small>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue'
export default {
setup() {
const username = ref('')
const email = ref('')
const searchTerm = ref('')
const text1 = ref('')
const text2 = ref('')
const text3 = ref('')
const disabledText = ref('無法編輯')
const validatedText = ref('')
const isValid = computed(() => validatedText.value.length >= 3)
const validateInput = () => {
// 驗證邏輯在 computed 中處理
}
return {
username,
email,
searchTerm,
text1,
text2,
text3,
disabledText,
validatedText,
isValid,
validateInput
}
}
}
</script>
<style scoped>
.field {
margin-bottom: 1.5rem;
}
.field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.size-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.p-error {
color: #e24c4c;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>
🔒 Password 密碼輸入框
<template>
<div class="password-examples">
<!-- 基本密碼框 -->
<div class="field">
<label for="password">密碼</label>
<Password
id="password"
v-model="password"
placeholder="請輸入密碼"
toggleMask
/>
</div>
<!-- 帶強度指示器 -->
<div class="field">
<label for="strongPassword">強密碼</label>
<Password
id="strongPassword"
v-model="strongPassword"
placeholder="請輸入強密碼"
:feedback="true"
toggleMask
/>
</div>
<!-- 自訂強度檢查 -->
<div class="field">
<label for="customPassword">自訂驗證密碼</label>
<Password
id="customPassword"
v-model="customPassword"
placeholder="密碼需包含大小寫字母和數字"
:feedback="false"
toggleMask
/>
<div class="password-strength">
<div class="strength-item" :class="{ 'valid': hasLowerCase }">
<i :class="hasLowerCase ? 'pi pi-check' : 'pi pi-times'"></i>
包含小寫字母
</div>
<div class="strength-item" :class="{ 'valid': hasUpperCase }">
<i :class="hasUpperCase ? 'pi pi-check' : 'pi pi-times'"></i>
包含大寫字母
</div>
<div class="strength-item" :class="{ 'valid': hasNumber }">
<i :class="hasNumber ? 'pi pi-check' : 'pi pi-times'"></i>
包含數字
</div>
<div class="strength-item" :class="{ 'valid': hasMinLength }">
<i :class="hasMinLength ? 'pi pi-check' : 'pi pi-times'"></i>
至少 8 個字元
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue'
import Password from 'primevue/password'
export default {
components: {
Password
},
setup() {
const password = ref('')
const strongPassword = ref('')
const customPassword = ref('')
// 密碼強度檢查
const hasLowerCase = computed(() => /[a-z]/.test(customPassword.value))
const hasUpperCase = computed(() => /[A-Z]/.test(customPassword.value))
const hasNumber = computed(() => /\d/.test(customPassword.value))
const hasMinLength = computed(() => customPassword.value.length >= 8)
return {
password,
strongPassword,
customPassword,
hasLowerCase,
hasUpperCase,
hasNumber,
hasMinLength
}
}
}
</script>
<style scoped>
.password-strength {
margin-top: 0.5rem;
}
.strength-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
color: #6c757d;
font-size: 0.875rem;
}
.strength-item.valid {
color: #198754;
}
.strength-item i {
font-size: 0.75rem;
}
</style>
📋 Dropdown 下拉選單
<template>
<div class="dropdown-examples">
<!-- 基本下拉選單 -->
<div class="field">
<label for="city">選擇城市</label>
<Dropdown
id="city"
v-model="selectedCity"
:options="cities"
optionLabel="name"
placeholder="請選擇城市"
/>
</div>
<!-- 可搜尋下拉選單 -->
<div class="field">
<label for="country">選擇國家</label>
<Dropdown
id="country"
v-model="selectedCountry"
:options="countries"
optionLabel="name"
placeholder="請選擇國家"
filter
filterPlaceholder="搜尋國家..."
/>
</div>
<!-- 分組下拉選單 -->
<div class="field">
<label for="category">選擇分類</label>
<Dropdown
id="category"
v-model="selectedCategory"
:options="categorizedItems"
optionLabel="label"
optionGroupLabel="category"
optionGroupChildren="items"
placeholder="請選擇分類"
/>
</div>
<!-- 自訂模板 -->
<div class="field">
<label for="product">選擇產品</label>
<Dropdown
id="product"
v-model="selectedProduct"
:options="products"
optionLabel="name"
placeholder="請選擇產品"
>
<template #value="slotProps">
<div v-if="slotProps.value" class="selected-product">
<img :src="slotProps.value.image" :alt="slotProps.value.name" />
<span>{{ slotProps.value.name }}</span>
</div>
<span v-else>{{ slotProps.placeholder }}</span>
</template>
<template #option="slotProps">
<div class="product-option">
<img :src="slotProps.option.image" :alt="slotProps.option.name" />
<div>
<div class="product-name">{{ slotProps.option.name }}</div>
<div class="product-price">${{ slotProps.option.price }}</div>
</div>
</div>
</template>
</Dropdown>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import Dropdown from 'primevue/dropdown'
export default {
components: {
Dropdown
},
setup() {
const selectedCity = ref(null)
const selectedCountry = ref(null)
const selectedCategory = ref(null)
const selectedProduct = ref(null)
const cities = ref([
{ name: '台北', code: 'TPE' },
{ name: '台中', code: 'TCH' },
{ name: '台南', code: 'TNN' },
{ name: '高雄', code: 'KHH' }
])
const countries = ref([
{ name: '台灣', code: 'TW' },
{ name: '日本', code: 'JP' },
{ name: '韓國', code: 'KR' },
{ name: '美國', code: 'US' },
{ name: '英國', code: 'UK' }
])
const categorizedItems = ref([
{
category: '前端框架',
items: [
{ label: 'Vue.js', value: 'vue' },
{ label: 'React', value: 'react' },
{ label: 'Angular', value: 'angular' }
]
},
{
category: '後端框架',
items: [
{ label: 'Express.js', value: 'express' },
{ label: 'Spring Boot', value: 'spring' },
{ label: 'Django', value: 'django' }
]
}
])
const products = ref([
{
name: 'iPhone 14',
price: 999,
image: 'https://via.placeholder.com/40x40?text=📱'
},
{
name: 'MacBook Pro',
price: 1999,
image: 'https://via.placeholder.com/40x40?text=💻'
},
{
name: 'iPad Air',
price: 599,
image: 'https://via.placeholder.com/40x40?text=📱'
}
])
return {
selectedCity,
selectedCountry,
selectedCategory,
selectedProduct,
cities,
countries,
categorizedItems,
products
}
}
}
</script>
<style scoped>
.selected-product,
.product-option {
display: flex;
align-items: center;
gap: 0.5rem;
}
.selected-product img,
.product-option img {
width: 32px;
height: 32px;
border-radius: 4px;
}
.product-name {
font-weight: 500;
}
.product-price {
color: #6c757d;
font-size: 0.875rem;
}
</style>
☑️ Checkbox 複選框
<template>
<div class="checkbox-examples">
<!-- 基本複選框 -->
<div class="field">
<div class="checkbox-group">
<Checkbox id="agree" v-model="agree" :binary="true" />
<label for="agree">我同意服務條款</label>
</div>
</div>
<!-- 多選複選框 -->
<div class="field">
<label>選擇您的興趣:</label>
<div class="checkbox-list">
<div v-for="interest in interests" :key="interest.key" class="checkbox-item">
<Checkbox
:id="interest.key"
v-model="selectedInterests"
:value="interest.key"
/>
<label :for="interest.key">{{ interest.name }}</label>
</div>
</div>
<small>已選擇:{{ selectedInterests.join(', ') || '無' }}</small>
</div>
<!-- 三狀態複選框 -->
<div class="field">
<label>功能權限</label>
<div class="permission-group">
<div class="checkbox-item">
<Checkbox id="selectAll" v-model="selectAll" @change="onSelectAllChange" />
<label for="selectAll">全選</label>
</div>
<div class="sub-permissions">
<div v-for="permission in permissions" :key="permission.key" class="checkbox-item">
<Checkbox
:id="permission.key"
v-model="selectedPermissions"
:value="permission.key"
@change="onPermissionChange"
/>
<label :for="permission.key">{{ permission.name }}</label>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue'
import Checkbox from 'primevue/checkbox'
export default {
components: {
Checkbox
},
setup() {
const agree = ref(false)
const selectedInterests = ref([])
const selectedPermissions = ref([])
const interests = ref([
{ key: 'programming', name: '程式設計' },
{ key: 'design', name: '設計' },
{ key: 'music', name: '音樂' },
{ key: 'sports', name: '運動' },
{ key: 'reading', name: '閱讀' }
])
const permissions = ref([
{ key: 'read', name: '讀取權限' },
{ key: 'write', name: '寫入權限' },
{ key: 'delete', name: '刪除權限' },
{ key: 'admin', name: '管理權限' }
])
const selectAll = computed({
get() {
if (selectedPermissions.value.length === 0) return false
if (selectedPermissions.value.length === permissions.value.length) return true
return null // 部分選中狀態
},
set(value) {
if (value) {
selectedPermissions.value = permissions.value.map(p => p.key)
} else {
selectedPermissions.value = []
}
}
})
const onSelectAllChange = () => {
// selectAll 的 computed setter 會處理邏輯
}
const onPermissionChange = () => {
// selectAll 的 computed getter 會自動更新
}
return {
agree,
selectedInterests,
selectedPermissions,
interests,
permissions,
selectAll,
onSelectAllChange,
onPermissionChange
}
}
}
</script>
<style scoped>
.checkbox-group,
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.checkbox-list {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.permission-group {
margin-top: 0.5rem;
}
.sub-permissions {
margin-left: 2rem;
margin-top: 0.5rem;
}
.checkbox-item label {
cursor: pointer;
user-select: none;
}
</style>
🔘 RadioButton 單選按鈕
<template>
<div class="radio-examples">
<!-- 基本單選按鈕 -->
<div class="field">
<label>選擇您的性別:</label>
<div class="radio-group">
<div class="radio-item">
<RadioButton id="male" v-model="gender" value="male" />
<label for="male">男性</label>
</div>
<div class="radio-item">
<RadioButton id="female" v-model="gender" value="female" />
<label for="female">女性</label>
</div>
<div class="radio-item">
<RadioButton id="other" v-model="gender" value="other" />
<label for="other">其他</label>
</div>
</div>
</div>
<!-- 產品選擇 -->
<div class="field">
<label>選擇產品方案:</label>
<div class="plan-group">
<div v-for="plan in plans" :key="plan.value" class="plan-item">
<RadioButton
:id="plan.value"
v-model="selectedPlan"
:value="plan.value"
/>
<label :for="plan.value" class="plan-label">
<div class="plan-name">{{ plan.name }}</div>
<div class="plan-price">${{ plan.price }}/月</div>
<div class="plan-description">{{ plan.description }}</div>
</label>
</div>
</div>
</div>
<!-- 支付方式 -->
<div class="field">
<label>選擇支付方式:</label>
<div class="payment-group">
<div v-for="payment in paymentMethods" :key="payment.value" class="payment-item">
<RadioButton
:id="payment.value"
v-model="selectedPayment"
:value="payment.value"
/>
<label :for="payment.value" class="payment-label">
<i :class="payment.icon"></i>
{{ payment.name }}
</label>
</div>
</div>
</div>
<div class="selection-summary">
<h4>您的選擇:</h4>
<p><strong>性別:</strong>{{ genderText }}</p>
<p><strong>方案:</strong>{{ planText }}</p>
<p><strong>支付方式:</strong>{{ paymentText }}</p>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue'
import RadioButton from 'primevue/radiobutton'
export default {
components: {
RadioButton
},
setup() {
const gender = ref('')
const selectedPlan = ref('')
const selectedPayment = ref('')
const plans = ref([
{
value: 'basic',
name: '基本方案',
price: 99,
description: '適合個人使用,包含基本功能'
},
{
value: 'pro',
name: '專業方案',
price: 199,
description: '適合小團隊,包含進階功能'
},
{
value: 'enterprise',
name: '企業方案',
price: 399,
description: '適合大型企業,包含所有功能'
}
])
const paymentMethods = ref([
{ value: 'credit', name: '信用卡', icon: 'pi pi-credit-card' },
{ value: 'paypal', name: 'PayPal', icon: 'pi pi-paypal' },
{ value: 'bank', name: '銀行轉帳', icon: 'pi pi-building' },
{ value: 'crypto', name: '加密貨幣', icon: 'pi pi-bitcoin' }
])
const genderText = computed(() => {
const genderMap = {
'male': '男性',
'female': '女性',
'other': '其他'
}
return genderMap[gender.value] || '未選擇'
})
const planText = computed(() => {
const plan = plans.value.find(p => p.value === selectedPlan.value)
return plan ? `${plan.name} ($${plan.price}/月)` : '未選擇'
})
const paymentText = computed(() => {
const payment = paymentMethods.value.find(p => p.value === selectedPayment.value)
return payment ? payment.name : '未選擇'
})
return {
gender,
selectedPlan,
selectedPayment,
plans,
paymentMethods,
genderText,
planText,
paymentText
}
}
}
</script>
<style scoped>
.radio-group,
.plan-group,
.payment-group {
margin-top: 0.5rem;
}
.radio-item,
.payment-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.plan-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 1rem;
border: 1px solid #e9ecef;
border-radius: 6px;
transition: border-color 0.3s;
}
.plan-item:hover {
border-color: #007ad9;
}
.plan-label {
cursor: pointer;
flex: 1;
}
.plan-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.plan-price {
color: #007ad9;
font-weight: 500;
margin-bottom: 0.25rem;
}
.plan-description {
color: #6c757d;
font-size: 0.875rem;
}
.payment-label {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.selection-summary {
margin-top: 2rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 6px;
}
.selection-summary h4 {
margin-bottom: 0.5rem;
color: #495057;
}
.selection-summary p {
margin-bottom: 0.25rem;
}
</style>
📅 Calendar 日期選擇器
<template>
<div class="calendar-examples">
<!-- 基本日期選擇器 -->
<div class="field">
<label for="birthDate">生日</label>
<Calendar
id="birthDate"
v-model="birthDate"
placeholder="請選擇生日"
dateFormat="yy/mm/dd"
/>
</div>
<!-- 日期範圍選擇 -->
<div class="field">
<label for="dateRange">選擇日期範圍</label>
<Calendar
id="dateRange"
v-model="dateRange"
placeholder="請選擇開始和結束日期"
selectionMode="range"
:numberOfMonths="2"
/>
</div>
<!-- 多日期選擇 -->
<div class="field">
<label for="multipleDates">選擇多個日期</label>
<Calendar
id="multipleDates"
v-model="multipleDates"
placeholder="請選擇多個日期"
selectionMode="multiple"
/>
</div>
<!-- 日期時間選擇器 -->
<div class="field">
<label for="dateTime">預約時間</label>
<Calendar
id="dateTime"
v-model="dateTime"
placeholder="請選擇日期和時間"
showTime
:showSeconds="true"
/>
</div>
<!-- 限制日期範圍 -->
<div class="field">
<label for="restrictedDate">會議日期(僅工作日)</label>
<Calendar
id="restrictedDate"
v-model="restrictedDate"
placeholder="請選擇會議日期"
:minDate="minDate"
:maxDate="maxDate"
:disabledDays="[0, 6]"
:disabledDates="holidays"
/>
</div>
<!-- 內聯顯示 -->
<div class="field">
<label>內聯日曆</label>
<Calendar
v-model="inlineDate"
:inline="true"
/>
</div>
<!-- 自訂格式 -->
<div class="field">
<label for="customFormat">自訂格式日期</label>
<Calendar
id="customFormat"
v-model="customFormatDate"
placeholder="請選擇日期"
dateFormat="dd/mm/yy"
:monthNavigator="true"
:yearNavigator="true"
yearRange="1900:2030"
/>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import Calendar from 'primevue/calendar'
export default {
components: {
Calendar
},
setup() {
const birthDate = ref(null)
const dateRange = ref(null)
const multipleDates = ref(null)
const dateTime = ref(null)
const restrictedDate = ref(null)
const inlineDate = ref(new Date())
const customFormatDate = ref(null)
// 設定日期限制
const today = new Date()
const minDate = ref(today)
const maxDate = ref(new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000)) // 30天後
// 假日日期
const holidays = ref([
new Date(2024, 0, 1), // 元旦
new Date(2024, 4, 1), // 勞動節
new Date(2024, 9, 10) // 國慶日
])
return {
birthDate,
dateRange,
multipleDates,
dateTime,
restrictedDate,
inlineDate,
customFormatDate,
minDate,
maxDate,
holidays
}
}
}
</script>
<style scoped>
.field {
margin-bottom: 2rem;
}
.field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
</style>
🎚️ Slider 滑桿
<template>
<div class="slider-examples">
<!-- 基本滑桿 -->
<div class="field">
<label for="basicSlider">音量:{{ volume }}%</label>
<Slider id="basicSlider" v-model="volume" />
</div>
<!-- 帶步進的滑桿 -->
<div class="field">
<label for="stepSlider">評分:{{ rating }} 分</label>
<Slider
id="stepSlider"
v-model="rating"
:min="0"
:max="10"
:step="0.5"
/>
</div>
<!-- 範圍滑桿 -->
<div class="field">
<label for="rangeSlider">價格範圍:${{ priceRange[0] }} - ${{ priceRange[1] }}</label>
<Slider
id="rangeSlider"
v-model="priceRange"
:range="true"
:min="0"
:max="1000"
:step="10"
/>
</div>
<!-- 垂直滑桿 -->
<div class="field">
<label>溫度控制</label>
<div class="vertical-slider-container">
<div class="temperature-display">
{{ temperature }}°C
</div>
<Slider
v-model="temperature"
orientation="vertical"
:min="16"
:max="30"
:step="1"
class="vertical-slider"
/>
</div>
</div>
<!-- 顏色滑桿組 -->
<div class="field">
<label>RGB 顏色調整</label>
<div class="color-sliders">
<div class="color-slider">
<label>紅色 ({{ red }})</label>
<Slider v-model="red" :min="0" :max="255" />
</div>
<div class="color-slider">
<label>綠色 ({{ green }})</label>
<Slider v-model="green" :min="0" :max="255" />
</div>
<div class="color-slider">
<label>藍色 ({{ blue }})</label>
<Slider v-model="blue" :min="0" :max="255" />
</div>
<div class="color-preview" :style="{ backgroundColor: rgbColor }">
顏色預覽
</div>
</div>
</div>
<!-- 進度指示滑桿 -->
<div class="field">
<label for="progressSlider">工作進度:{{ progress }}%</label>
<Slider
id="progressSlider"
v-model="progress"
:min="0"
:max="100"
class="progress-slider"
/>
<div class="progress-status">
<span v-if="progress < 25" class="status-low">剛開始</span>
<span v-else-if="progress < 50" class="status-medium">進行中</span>
<span v-else-if="progress < 75" class="status-high">接近完成</span>
<span v-else class="status-complete">已完成</span>
</div>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue'
import Slider from 'primevue/slider'
export default {
components: {
Slider
},
setup() {
const volume = ref(50)
const rating = ref(7.5)
const priceRange = ref([100, 500])
const temperature = ref(22)
const red = ref(128)
const green = ref(128)
const blue = ref(128)
const progress = ref(30)
const rgbColor = computed(() => {
return `rgb(${red.value}, ${green.value}, ${blue.value})`
})
return {
volume,
rating,
priceRange,
temperature,
red,
green,
blue,
progress,
rgbColor
}
}
}
</script>
<style scoped>
.field {
margin-bottom: 2rem;
}
.field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.vertical-slider-container {
display: flex;
align-items: center;
gap: 2rem;
height: 200px;
}
.temperature-display {
font-size: 2rem;
font-weight: bold;
color: #007ad9;
}
.vertical-slider {
height: 200px;
}
.color-sliders {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
}
.color-slider {
margin-bottom: 1rem;
}
.color-slider label {
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.color-preview {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
border-radius: 4px;
margin-top: 1rem;
}
.progress-slider {
margin-bottom: 0.5rem;
}
.progress-status {
text-align: center;
font-weight: 500;
}
.status-low { color: #dc3545; }
.status-medium { color: #fd7e14; }
.status-high { color: #ffc107; }
.status-complete { color: #198754; }
</style>
2.3 資料顯示元件(DataTable、Listbox、Card、Panel、TabView、Accordion)
📊 DataTable 資料表格
DataTable 是 PrimeVue 最強大的元件之一,提供完整的資料展示和操作功能。
基本資料表格
<template>
<div class="datatable-examples">
<!-- 基本表格 -->
<div class="table-section">
<h3>員工資料表</h3>
<DataTable :value="employees" tableStyle="min-width: 50rem">
<Column field="name" header="姓名"></Column>
<Column field="department" header="部門"></Column>
<Column field="position" header="職位"></Column>
<Column field="salary" header="薪資">
<template #body="slotProps">
${{ slotProps.data.salary.toLocaleString() }}
</template>
</Column>
<Column field="startDate" header="到職日期">
<template #body="slotProps">
{{ formatDate(slotProps.data.startDate) }}
</template>
</Column>
</DataTable>
</div>
<!-- 可排序表格 -->
<div class="table-section">
<h3>可排序表格</h3>
<DataTable :value="products" sortMode="multiple" tableStyle="min-width: 50rem">
<Column field="name" header="產品名稱" sortable></Column>
<Column field="category" header="分類" sortable></Column>
<Column field="price" header="價格" sortable>
<template #body="slotProps">
${{ slotProps.data.price }}
</template>
</Column>
<Column field="rating" header="評分" sortable>
<template #body="slotProps">
<div class="rating">
<span class="rating-value">{{ slotProps.data.rating }}</span>
<span class="rating-stars">{{ '★'.repeat(Math.floor(slotProps.data.rating)) }}</span>
</div>
</template>
</Column>
<Column field="stock" header="庫存" sortable>
<template #body="slotProps">
<span :class="getStockClass(slotProps.data.stock)">
{{ slotProps.data.stock }}
</span>
</template>
</Column>
</DataTable>
</div>
<!-- 可分頁表格 -->
<div class="table-section">
<h3>分頁表格</h3>
<DataTable
:value="customers"
:paginator="true"
:rows="5"
:rowsPerPageOptions="[5, 10, 20]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
currentPageReportTemplate="顯示 {first} 到 {last} 筆,共 {totalRecords} 筆記錄"
tableStyle="min-width: 50rem"
>
<Column field="name" header="客戶名稱"></Column>
<Column field="company" header="公司"></Column>
<Column field="email" header="Email"></Column>
<Column field="phone" header="電話"></Column>
<Column field="status" header="狀態">
<template #body="slotProps">
<span :class="getStatusClass(slotProps.data.status)">
{{ slotProps.data.status }}
</span>
</template>
</Column>
</DataTable>
</div>
<!-- 可篩選表格 -->
<div class="table-section">
<h3>可篩選表格</h3>
<DataTable
:value="orders"
:filters="filters"
filterDisplay="menu"
:globalFilterFields="['customer', 'product', 'status']"
tableStyle="min-width: 50rem"
>
<template #header>
<div class="table-header">
<h4>訂單管理</h4>
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText v-model="filters['global'].value" placeholder="搜尋..." />
</span>
</div>
</template>
<Column field="id" header="訂單編號" sortable></Column>
<Column field="customer" header="客戶" sortable></Column>
<Column field="product" header="產品" sortable></Column>
<Column field="amount" header="金額" sortable>
<template #body="slotProps">
${{ slotProps.data.amount.toLocaleString() }}
</template>
</Column>
<Column field="status" header="狀態" sortable>
<template #body="slotProps">
<span :class="getOrderStatusClass(slotProps.data.status)">
{{ slotProps.data.status }}
</span>
</template>
<template #filter="{ filterModel }">
<Dropdown
v-model="filterModel.value"
:options="orderStatuses"
placeholder="選擇狀態"
class="p-column-filter"
showClear
/>
</template>
</Column>
<Column header="操作">
<template #body="slotProps">
<div class="action-buttons">
<Button
icon="pi pi-eye"
class="p-button-rounded p-button-info p-button-sm"
@click="viewOrder(slotProps.data)"
v-tooltip="'查看'"
/>
<Button
icon="pi pi-pencil"
class="p-button-rounded p-button-success p-button-sm"
@click="editOrder(slotProps.data)"
v-tooltip="'編輯'"
/>
<Button
icon="pi pi-trash"
class="p-button-rounded p-button-danger p-button-sm"
@click="deleteOrder(slotProps.data)"
v-tooltip="'刪除'"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<!-- 可選取表格 -->
<div class="table-section">
<h3>可選取表格</h3>
<DataTable
:value="selectableProducts"
v-model:selection="selectedProducts"
selectionMode="multiple"
dataKey="id"
tableStyle="min-width: 50rem"
>
<template #header>
<div class="selection-header">
<span>已選擇 {{ selectedProducts.length }} 個項目</span>
<Button
label="批次刪除"
icon="pi pi-trash"
class="p-button-danger"
:disabled="!selectedProducts.length"
@click="bulkDelete"
/>
</div>
</template>
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
<Column field="name" header="產品名稱"></Column>
<Column field="category" header="分類"></Column>
<Column field="price" header="價格">
<template #body="slotProps">
${{ slotProps.data.price }}
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script>
import { ref, reactive } from 'vue'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dropdown from 'primevue/dropdown'
import { FilterMatchMode } from 'primevue/api'
export default {
components: {
DataTable,
Column,
Dropdown
},
setup() {
// 員工資料
const employees = ref([
{
name: '張小明',
department: '工程部',
position: '前端工程師',
salary: 80000,
startDate: new Date('2023-01-15')
},
{
name: '李小華',
department: '設計部',
position: 'UI/UX 設計師',
salary: 75000,
startDate: new Date('2023-03-01')
},
{
name: '王小美',
department: '產品部',
position: '產品經理',
salary: 90000,
startDate: new Date('2022-08-20')
}
])
// 產品資料
const products = ref([
{ name: 'iPhone 14', category: '手機', price: 999, rating: 4.5, stock: 25 },
{ name: 'MacBook Pro', category: '筆電', price: 1999, rating: 4.8, stock: 8 },
{ name: 'iPad Air', category: '平板', price: 599, rating: 4.3, stock: 0 },
{ name: 'Apple Watch', category: '穿戴裝置', price: 399, rating: 4.2, stock: 15 }
])
// 客戶資料
const customers = ref([
{ name: '陳大明', company: 'ABC 公司', email: 'chen@abc.com', phone: '0912-345-678', status: '活躍' },
{ name: '林小雅', company: 'XYZ 企業', email: 'lin@xyz.com', phone: '0923-456-789', status: '休眠' },
{ name: '劉志強', company: 'DEF 集團', email: 'liu@def.com', phone: '0934-567-890', status: '活躍' },
{ name: '黃美麗', company: 'GHI 有限公司', email: 'huang@ghi.com', phone: '0945-678-901', status: '新客戶' },
{ name: '吳建宏', company: 'JKL 科技', email: 'wu@jkl.com', phone: '0956-789-012', status: '活躍' },
{ name: '蔡淑芬', company: 'MNO 顧問', email: 'tsai@mno.com', phone: '0967-890-123', status: '休眠' }
])
// 訂單資料
const orders = ref([
{ id: 'ORD-001', customer: '張小明', product: 'iPhone 14', amount: 29970, status: '已完成' },
{ id: 'ORD-002', customer: '李小華', product: 'MacBook Pro', amount: 59970, status: '處理中' },
{ id: 'ORD-003', customer: '王小美', product: 'iPad Air', amount: 17970, status: '已取消' },
{ id: 'ORD-004', customer: '陳大明', product: 'Apple Watch', amount: 11970, status: '已完成' }
])
// 可選取產品
const selectableProducts = ref([
{ id: 1, name: 'iPhone 14', category: '手機', price: 999 },
{ id: 2, name: 'MacBook Pro', category: '筆電', price: 1999 },
{ id: 3, name: 'iPad Air', category: '平板', price: 599 },
{ id: 4, name: 'Apple Watch', category: '穿戴裝置', price: 399 }
])
const selectedProducts = ref([])
// 篩選器設定
const filters = reactive({
'global': { value: null, matchMode: FilterMatchMode.CONTAINS }
})
const orderStatuses = ref(['已完成', '處理中', '已取消', '待付款'])
// 工具函數
const formatDate = (date) => {
return date.toLocaleDateString('zh-TW')
}
const getStockClass = (stock) => {
if (stock === 0) return 'stock-out'
if (stock < 10) return 'stock-low'
return 'stock-normal'
}
const getStatusClass = (status) => {
const statusMap = {
'活躍': 'status-active',
'休眠': 'status-inactive',
'新客戶': 'status-new'
}
return statusMap[status] || ''
}
const getOrderStatusClass = (status) => {
const statusMap = {
'已完成': 'order-completed',
'處理中': 'order-processing',
'已取消': 'order-cancelled',
'待付款': 'order-pending'
}
return statusMap[status] || ''
}
// 操作函數
const viewOrder = (order) => {
console.log('查看訂單:', order)
}
const editOrder = (order) => {
console.log('編輯訂單:', order)
}
const deleteOrder = (order) => {
console.log('刪除訂單:', order)
}
const bulkDelete = () => {
console.log('批次刪除:', selectedProducts.value)
}
return {
employees,
products,
customers,
orders,
selectableProducts,
selectedProducts,
filters,
orderStatuses,
formatDate,
getStockClass,
getStatusClass,
getOrderStatusClass,
viewOrder,
editOrder,
deleteOrder,
bulkDelete
}
}
}
</script>
<style scoped>
.datatable-examples {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.table-section {
margin-bottom: 3rem;
padding: 1.5rem;
border: 1px solid #e9ecef;
border-radius: 8px;
}
.table-section h3 {
margin-bottom: 1rem;
color: #495057;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.selection-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.rating {
display: flex;
align-items: center;
gap: 0.5rem;
}
.rating-stars {
color: #ffd700;
}
/* 庫存狀態樣式 */
.stock-normal { color: #198754; font-weight: 500; }
.stock-low { color: #fd7e14; font-weight: 500; }
.stock-out { color: #dc3545; font-weight: 500; }
/* 客戶狀態樣式 */
.status-active {
background: #d1edff;
color: #007ad9;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.status-inactive {
background: #f8d7da;
color: #721c24;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.status-new {
background: #d4edda;
color: #155724;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
/* 訂單狀態樣式 */
.order-completed {
background: #d4edda;
color: #155724;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.order-processing {
background: #fff3cd;
color: #856404;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.order-cancelled {
background: #f8d7da;
color: #721c24;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.order-pending {
background: #d1ecf1;
color: #0c5460;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.table-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.selection-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.action-buttons {
justify-content: center;
}
}
</style>
📋 Listbox 清單框
<template>
<div class="listbox-examples">
<!-- 基本清單框 -->
<div class="listbox-section">
<h3>選擇城市</h3>
<Listbox
v-model="selectedCity"
:options="cities"
optionLabel="name"
class="listbox"
/>
<p>已選擇:{{ selectedCity?.name || '無' }}</p>
</div>
<!-- 多選清單框 -->
<div class="listbox-section">
<h3>選擇多個技能</h3>
<Listbox
v-model="selectedSkills"
:options="skills"
optionLabel="name"
multiple
class="listbox"
/>
<p>已選擇:{{ selectedSkills.map(s => s.name).join(', ') || '無' }}</p>
</div>
<!-- 可搜尋清單框 -->
<div class="listbox-section">
<h3>選擇國家</h3>
<Listbox
v-model="selectedCountry"
:options="countries"
optionLabel="name"
filter
filterPlaceholder="搜尋國家..."
class="listbox"
/>
</div>
<!-- 自訂模板清單框 -->
<div class="listbox-section">
<h3>選擇員工</h3>
<Listbox
v-model="selectedEmployee"
:options="employees"
optionLabel="name"
class="listbox"
>
<template #option="slotProps">
<div class="employee-option">
<img :src="slotProps.option.avatar" :alt="slotProps.option.name" class="employee-avatar" />
<div class="employee-info">
<div class="employee-name">{{ slotProps.option.name }}</div>
<div class="employee-role">{{ slotProps.option.role }}</div>
<div class="employee-department">{{ slotProps.option.department }}</div>
</div>
</div>
</template>
</Listbox>
</div>
<!-- 分組清單框 -->
<div class="listbox-section">
<h3>選擇產品</h3>
<Listbox
v-model="selectedProduct"
:options="groupedProducts"
optionLabel="name"
optionGroupLabel="category"
optionGroupChildren="items"
class="listbox"
>
<template #optiongroup="slotProps">
<div class="product-category">
<i :class="slotProps.option.icon"></i>
{{ slotProps.option.category }}
</div>
</template>
</Listbox>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import Listbox from 'primevue/listbox'
export default {
components: {
Listbox
},
setup() {
const selectedCity = ref(null)
const selectedSkills = ref([])
const selectedCountry = ref(null)
const selectedEmployee = ref(null)
const selectedProduct = ref(null)
const cities = ref([
{ name: '台北', code: 'TPE' },
{ name: '新北', code: 'NTP' },
{ name: '桃園', code: 'TYN' },
{ name: '台中', code: 'TCH' },
{ name: '台南', code: 'TNN' },
{ name: '高雄', code: 'KHH' }
])
const skills = ref([
{ name: 'JavaScript', level: 'Advanced' },
{ name: 'Vue.js', level: 'Advanced' },
{ name: 'React', level: 'Intermediate' },
{ name: 'Node.js', level: 'Intermediate' },
{ name: 'Python', level: 'Beginner' },
{ name: 'Java', level: 'Intermediate' },
{ name: 'SQL', level: 'Advanced' }
])
const countries = ref([
{ name: '台灣', code: 'TW', flag: '🇹🇼' },
{ name: '日本', code: 'JP', flag: '🇯🇵' },
{ name: '韓國', code: 'KR', flag: '🇰🇷' },
{ name: '美國', code: 'US', flag: '🇺🇸' },
{ name: '英國', code: 'UK', flag: '🇬🇧' },
{ name: '法國', code: 'FR', flag: '🇫🇷' },
{ name: '德國', code: 'DE', flag: '🇩🇪' }
])
const employees = ref([
{
name: '張小明',
role: '前端工程師',
department: '工程部',
avatar: 'https://via.placeholder.com/40x40?text=張'
},
{
name: '李小華',
role: 'UI/UX 設計師',
department: '設計部',
avatar: 'https://via.placeholder.com/40x40?text=李'
},
{
name: '王小美',
role: '產品經理',
department: '產品部',
avatar: 'https://via.placeholder.com/40x40?text=王'
},
{
name: '陳大明',
role: '後端工程師',
department: '工程部',
avatar: 'https://via.placeholder.com/40x40?text=陳'
}
])
const groupedProducts = ref([
{
category: '電子產品',
icon: 'pi pi-mobile',
items: [
{ name: 'iPhone 14', price: 999 },
{ name: 'Samsung Galaxy S23', price: 899 },
{ name: 'MacBook Pro', price: 1999 }
]
},
{
category: '服飾',
icon: 'pi pi-shopping-bag',
items: [
{ name: 'Nike 運動鞋', price: 120 },
{ name: 'Adidas T-shirt', price: 35 },
{ name: 'Levi\'s 牛仔褲', price: 80 }
]
},
{
category: '書籍',
icon: 'pi pi-book',
items: [
{ name: 'Vue.js 權威指南', price: 45 },
{ name: 'JavaScript 高級程式設計', price: 55 },
{ name: '設計模式', price: 40 }
]
}
])
return {
selectedCity,
selectedSkills,
selectedCountry,
selectedEmployee,
selectedProduct,
cities,
skills,
countries,
employees,
groupedProducts
}
}
}
</script>
<style scoped>
.listbox-examples {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
padding: 1rem;
}
.listbox-section {
padding: 1.5rem;
border: 1px solid #e9ecef;
border-radius: 8px;
}
.listbox-section h3 {
margin-bottom: 1rem;
color: #495057;
}
.listbox {
width: 100%;
height: 200px;
margin-bottom: 1rem;
}
.employee-option {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem;
}
.employee-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.employee-info {
flex: 1;
}
.employee-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.employee-role {
font-size: 0.875rem;
color: #007ad9;
margin-bottom: 0.25rem;
}
.employee-department {
font-size: 0.75rem;
color: #6c757d;
}
.product-category {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #495057;
}
@media (max-width: 768px) {
.listbox-examples {
grid-template-columns: 1fr;
}
}
</style>
🎴 Card 卡片
Card 是一個靈活的容器元件,用於展示相關內容的組合。
<template>
<div class="card-examples">
<!-- 基本卡片 -->
<div class="card-section">
<h3>基本卡片</h3>
<div class="card-grid">
<Card class="basic-card">
<template #title>基本資訊</template>
<template #content>
<p>這是一個基本的卡片範例,包含標題和內容區域。</p>
</template>
</Card>
<Card class="basic-card">
<template #title>使用者資料</template>
<template #subtitle>個人資訊管理</template>
<template #content>
<div class="user-info">
<p><strong>姓名:</strong>張小明</p>
<p><strong>部門:</strong>工程部</p>
<p><strong>職位:</strong>前端工程師</p>
</div>
</template>
<template #footer>
<div class="card-footer">
<Button label="編輯" icon="pi pi-pencil" class="p-button-sm" />
<Button label="刪除" icon="pi pi-trash" class="p-button-sm p-button-danger" />
</div>
</template>
</Card>
</div>
</div>
<!-- 產品卡片 -->
<div class="card-section">
<h3>產品展示卡片</h3>
<div class="product-grid">
<Card v-for="product in products" :key="product.id" class="product-card">
<template #header>
<img :src="product.image" :alt="product.name" class="product-image" />
</template>
<template #title>{{ product.name }}</template>
<template #subtitle>${{ product.price }}</template>
<template #content>
<div class="product-details">
<p>{{ product.description }}</p>
<div class="product-rating">
<span class="rating-stars">{{ '★'.repeat(Math.floor(product.rating)) }}</span>
<span class="rating-value">({{ product.rating }})</span>
</div>
<div class="product-tags">
<span v-for="tag in product.tags" :key="tag" class="product-tag">{{ tag }}</span>
</div>
</div>
</template>
<template #footer>
<div class="product-actions">
<Button
label="加入購物車"
icon="pi pi-shopping-cart"
class="p-button-success"
@click="addToCart(product)"
/>
<Button
icon="pi pi-heart"
class="p-button-outlined p-button-secondary"
@click="toggleWishlist(product)"
/>
</div>
</template>
</Card>
</div>
</div>
<!-- 統計卡片 -->
<div class="card-section">
<h3>統計儀表板</h3>
<div class="stats-grid">
<Card v-for="stat in statistics" :key="stat.title" class="stat-card">
<template #content>
<div class="stat-content">
<div class="stat-icon" :style="{ backgroundColor: stat.color }">
<i :class="stat.icon"></i>
</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-change" :class="stat.trend">
<i :class="stat.trendIcon"></i>
{{ stat.change }}
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- 互動式卡片 -->
<div class="card-section">
<h3>互動式卡片</h3>
<div class="interactive-cards">
<Card class="feature-card" @click="toggleFeature('analytics')">
<template #content>
<div class="feature-content">
<i class="pi pi-chart-line feature-icon"></i>
<h4>數據分析</h4>
<p>深入分析您的業務數據</p>
<div class="feature-status" :class="{ active: features.analytics }">
{{ features.analytics ? '已啟用' : '點擊啟用' }}
</div>
</div>
</template>
</Card>
<Card class="feature-card" @click="toggleFeature('notifications')">
<template #content>
<div class="feature-content">
<i class="pi pi-bell feature-icon"></i>
<h4>通知系統</h4>
<p>即時接收重要通知</p>
<div class="feature-status" :class="{ active: features.notifications }">
{{ features.notifications ? '已啟用' : '點擊啟用' }}
</div>
</div>
</template>
</Card>
<Card class="feature-card" @click="toggleFeature('security')">
<template #content>
<div class="feature-content">
<i class="pi pi-shield feature-icon"></i>
<h4>安全防護</h4>
<p>保護您的帳戶安全</p>
<div class="feature-status" :class="{ active: features.security }">
{{ features.security ? '已啟用' : '點擊啟用' }}
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import Card from 'primevue/card'
export default {
components: {
Card
},
setup() {
const products = ref([
{
id: 1,
name: 'iPhone 14 Pro',
price: 999,
description: '專業級攝影系統,強大的 A16 仿生晶片',
rating: 4.5,
tags: ['熱門', '新品'],
image: 'https://via.placeholder.com/300x200?text=iPhone+14+Pro'
},
{
id: 2,
name: 'MacBook Air M2',
price: 1199,
description: '超薄設計,卓越效能,全天候電池續航',
rating: 4.8,
tags: ['推薦', '輕薄'],
image: 'https://via.placeholder.com/300x200?text=MacBook+Air'
},
{
id: 3,
name: 'iPad Pro',
price: 799,
description: '專業創作工具,支援 Apple Pencil',
rating: 4.6,
tags: ['創作', '專業'],
image: 'https://via.placeholder.com/300x200?text=iPad+Pro'
}
])
const statistics = ref([
{
title: '總用戶數',
value: '12,345',
change: '+5.2%',
trend: 'positive',
trendIcon: 'pi pi-arrow-up',
icon: 'pi pi-users',
color: '#3B82F6'
},
{
title: '營收',
value: '$89,432',
change: '+12.8%',
trend: 'positive',
trendIcon: 'pi pi-arrow-up',
icon: 'pi pi-dollar',
color: '#10B981'
},
{
title: '訂單數',
value: '2,856',
change: '-2.1%',
trend: 'negative',
trendIcon: 'pi pi-arrow-down',
icon: 'pi pi-shopping-cart',
color: '#F59E0B'
},
{
title: '轉換率',
value: '3.2%',
change: '+0.8%',
trend: 'positive',
trendIcon: 'pi pi-arrow-up',
icon: 'pi pi-percentage',
color: '#8B5CF6'
}
])
const features = ref({
analytics: false,
notifications: true,
security: true
})
const addToCart = (product) => {
console.log('加入購物車:', product.name)
}
const toggleWishlist = (product) => {
console.log('加入/移除願望清單:', product.name)
}
const toggleFeature = (featureName) => {
features.value[featureName] = !features.value[featureName]
}
return {
products,
statistics,
features,
addToCart,
toggleWishlist,
toggleFeature
}
}
}
</script>
<style scoped>
.card-examples {
padding: 1rem;
}
.card-section {
margin-bottom: 3rem;
}
.card-section h3 {
margin-bottom: 1.5rem;
color: #495057;
}
/* 基本卡片樣式 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.basic-card {
height: fit-content;
}
.user-info p {
margin-bottom: 0.5rem;
}
.card-footer {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* 產品卡片樣式 */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.product-card {
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
}
.product-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.product-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.product-details p {
margin-bottom: 1rem;
color: #6c757d;
}
.product-rating {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.rating-stars {
color: #ffd700;
}
.rating-value {
color: #6c757d;
font-size: 0.875rem;
}
.product-tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.product-tag {
background: #e3f2fd;
color: #1976d2;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.product-actions {
display: flex;
gap: 0.5rem;
justify-content: space-between;
}
/* 統計卡片樣式 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-content {
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.stat-title {
font-size: 0.875rem;
opacity: 0.9;
margin-bottom: 0.5rem;
}
.stat-change {
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.stat-change.positive {
color: #4ade80;
}
.stat-change.negative {
color: #f87171;
}
/* 互動式卡片樣式 */
.interactive-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.feature-card {
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
border-color: #007ad9;
}
.feature-content {
text-align: center;
padding: 1rem;
}
.feature-icon {
font-size: 3rem;
color: #007ad9;
margin-bottom: 1rem;
}
.feature-content h4 {
margin-bottom: 0.5rem;
color: #495057;
}
.feature-content p {
color: #6c757d;
margin-bottom: 1rem;
}
.feature-status {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
background: #f8f9fa;
color: #6c757d;
transition: all 0.3s ease;
}
.feature-status.active {
background: #d4edda;
color: #155724;
}
@media (max-width: 768px) {
.card-grid,
.product-grid,
.stats-grid,
.interactive-cards {
grid-template-columns: 1fr;
}
.product-actions {
flex-direction: column;
}
.stat-content {
flex-direction: column;
text-align: center;
}
}
</style>
📋 Panel 面板
Panel 提供可摺疊的內容容器,適合組織大量資訊。
<template>
<div class="panel-examples">
<!-- 基本面板 -->
<div class="panel-section">
<h3>基本面板</h3>
<Panel header="系統設定" :toggleable="true">
<p>這是一個可摺疊的面板,可以用來組織相關的設定選項。</p>
<div class="setting-group">
<div class="setting-item">
<label>啟用通知</label>
<Checkbox v-model="settings.notifications" :binary="true" />
</div>
<div class="setting-item">
<label>自動儲存</label>
<Checkbox v-model="settings.autoSave" :binary="true" />
</div>
<div class="setting-item">
<label>深色模式</label>
<Checkbox v-model="settings.darkMode" :binary="true" />
</div>
</div>
</Panel>
</div>
<!-- 自訂標題面板 -->
<div class="panel-section">
<h3>自訂標題面板</h3>
<Panel :toggleable="true">
<template #header>
<div class="custom-header">
<i class="pi pi-user"></i>
<span>使用者資訊</span>
<span class="header-badge">{{ users.length }}</span>
</div>
</template>
<DataTable :value="users" tableStyle="min-width: 100%">
<Column field="name" header="姓名"></Column>
<Column field="email" header="Email"></Column>
<Column field="role" header="角色"></Column>
</DataTable>
</Panel>
</div>
<!-- 多個摺疊面板 -->
<div class="panel-section">
<h3>資訊面板組</h3>
<div class="panel-group">
<Panel
v-for="info in infoSections"
:key="info.id"
:header="info.title"
:toggleable="true"
:collapsed="info.collapsed"
class="info-panel"
>
<template #icons>
<i :class="info.icon" class="panel-icon"></i>
</template>
<div class="info-content">
<p>{{ info.description }}</p>
<div v-if="info.items" class="info-list">
<div v-for="item in info.items" :key="item" class="info-item">
<i class="pi pi-check-circle"></i>
{{ item }}
</div>
</div>
</div>
</Panel>
</div>
</div>
<!-- 帶操作按鈕的面板 -->
<div class="panel-section">
<h3>操作面板</h3>
<Panel header="檔案管理" :toggleable="true">
<template #icons>
<Button
icon="pi pi-plus"
class="p-button-rounded p-button-text p-button-sm"
@click="addFile"
v-tooltip="'新增檔案'"
/>
<Button
icon="pi pi-refresh"
class="p-button-rounded p-button-text p-button-sm"
@click="refreshFiles"
v-tooltip="'重新整理'"
/>
</template>
<div class="file-manager">
<div v-for="file in files" :key="file.id" class="file-item">
<div class="file-info">
<i :class="getFileIcon(file.type)"></i>
<div class="file-details">
<div class="file-name">{{ file.name }}</div>
<div class="file-meta">{{ file.size }} • {{ file.modifiedDate }}</div>
</div>
</div>
<div class="file-actions">
<Button
icon="pi pi-download"
class="p-button-rounded p-button-text p-button-sm"
@click="downloadFile(file)"
/>
<Button
icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-sm p-button-danger"
@click="deleteFile(file)"
/>
</div>
</div>
</div>
</Panel>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import Panel from 'primevue/panel'
import Checkbox from 'primevue/checkbox'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
export default {
components: {
Panel,
Checkbox,
DataTable,
Column
},
setup() {
const settings = ref({
notifications: true,
autoSave: false,
darkMode: false
})
const users = ref([
{ name: '張小明', email: 'zhang@example.com', role: '管理員' },
{ name: '李小華', email: 'li@example.com', role: '使用者' },
{ name: '王小美', email: 'wang@example.com', role: '編輯者' }
])
const infoSections = ref([
{
id: 1,
title: '產品特色',
icon: 'pi pi-star',
collapsed: false,
description: '我們的產品具有以下優秀特色:',
items: ['高效能處理', '直觀的使用介面', '完整的安全防護', '24/7 技術支援']
},
{
id: 2,
title: '技術規格',
icon: 'pi pi-cog',
collapsed: true,
description: '詳細的技術規格資訊:',
items: ['支援最新標準', '跨平台相容', '雲端整合', 'API 支援']
},
{
id: 3,
title: '服務條款',
icon: 'pi pi-file-text',
collapsed: true,
description: '使用本服務前請詳閱相關條款。',
items: ['使用授權', '隱私保護', '服務限制', '責任聲明']
}
])
const files = ref([
{
id: 1,
name: '專案報告.pdf',
type: 'pdf',
size: '2.5 MB',
modifiedDate: '2024-01-15'
},
{
id: 2,
name: '資料分析.xlsx',
type: 'excel',
size: '1.2 MB',
modifiedDate: '2024-01-14'
},
{
id: 3,
name: '設計稿.png',
type: 'image',
size: '850 KB',
modifiedDate: '2024-01-13'
},
{
id: 4,
name: '會議記錄.docx',
type: 'word',
size: '320 KB',
modifiedDate: '2024-01-12'
}
])
const getFileIcon = (fileType) => {
const iconMap = {
'pdf': 'pi pi-file-pdf',
'excel': 'pi pi-file-excel',
'word': 'pi pi-file-word',
'image': 'pi pi-image',
'video': 'pi pi-video',
'audio': 'pi pi-volume-up'
}
return iconMap[fileType] || 'pi pi-file'
}
const addFile = () => {
console.log('新增檔案')
}
const refreshFiles = () => {
console.log('重新整理檔案清單')
}
const downloadFile = (file) => {
console.log('下載檔案:', file.name)
}
const deleteFile = (file) => {
console.log('刪除檔案:', file.name)
}
return {
settings,
users,
infoSections,
files,
getFileIcon,
addFile,
refreshFiles,
downloadFile,
deleteFile
}
}
}
</script>
<style scoped>
.panel-examples {
padding: 1rem;
}
.panel-section {
margin-bottom: 2rem;
}
.panel-section h3 {
margin-bottom: 1rem;
color: #495057;
}
.setting-group {
margin-top: 1rem;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #e9ecef;
}
.setting-item:last-child {
border-bottom: none;
}
.custom-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-badge {
background: #007ad9;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
}
.panel-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.info-panel {
border: 1px solid #e9ecef;
}
.panel-icon {
margin-right: 0.5rem;
color: #007ad9;
}
.info-content p {
margin-bottom: 1rem;
color: #495057;
}
.info-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: #198754;
}
.file-manager {
display: flex;
flex-direction: column;
gap: 1rem;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #e9ecef;
border-radius: 6px;
transition: background-color 0.2s;
}
.file-item:hover {
background-color: #f8f9fa;
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
}
.file-info i {
font-size: 2rem;
color: #007ad9;
}
.file-details {
display: flex;
flex-direction: column;
}
.file-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.file-meta {
font-size: 0.875rem;
color: #6c757d;
}
.file-actions {
display: flex;
gap: 0.5rem;
}
#### 📑 TabView 分頁檢視
TabView 提供分頁式的內容組織方式,讓使用者可以在不同內容區域間切換。
```vue
<template>
<div class="tabview-examples">
<!-- 基本分頁 -->
<div class="tab-section">
<h3>基本分頁檢視</h3>
<TabView>
<TabPanel header="個人資料">
<div class="tab-content">
<h4>個人基本資料</h4>
<div class="form-grid">
<div class="field">
<label for="name">姓名</label>
<InputText id="name" v-model="profile.name" />
</div>
<div class="field">
<label for="email">Email</label>
<InputText id="email" v-model="profile.email" />
</div>
<div class="field">
<label for="phone">電話</label>
<InputText id="phone" v-model="profile.phone" />
</div>
<div class="field">
<label for="address">地址</label>
<InputText id="address" v-model="profile.address" />
</div>
</div>
</div>
</TabPanel>
<TabPanel header="帳戶設定">
<div class="tab-content">
<h4>帳戶安全設定</h4>
<div class="settings-list">
<div class="setting-item">
<div class="setting-info">
<h5>變更密碼</h5>
<p>定期變更密碼以確保帳戶安全</p>
</div>
<Button label="變更" class="p-button-outlined" />
</div>
<div class="setting-item">
<div class="setting-info">
<h5>雙重認證</h5>
<p>增加帳戶安全層級</p>
</div>
<Button label="設定" class="p-button-outlined" />
</div>
<div class="setting-item">
<div class="setting-info">
<h5>登入記錄</h5>
<p>查看最近的登入活動</p>
</div>
<Button label="查看" class="p-button-outlined" />
</div>
</div>
</div>
</TabPanel>
<TabPanel header="偏好設定">
<div class="tab-content">
<h4>個人偏好</h4>
<div class="preference-grid">
<div class="preference-item">
<label>語言</label>
<Dropdown
v-model="preferences.language"
:options="languages"
optionLabel="name"
optionValue="code"
/>
</div>
<div class="preference-item">
<label>時區</label>
<Dropdown
v-model="preferences.timezone"
:options="timezones"
optionLabel="name"
optionValue="value"
/>
</div>
<div class="preference-item">
<label>主題</label>
<Dropdown
v-model="preferences.theme"
:options="themes"
optionLabel="name"
optionValue="value"
/>
</div>
</div>
</div>
</TabPanel>
</TabView>
</div>
<!-- 可關閉的分頁 -->
<div class="tab-section">
<h3>可關閉分頁</h3>
<TabView>
<TabPanel
v-for="tab in closableTabs"
:key="tab.id"
:header="tab.title"
:closable="tab.closable"
@tab-close="closeTab(tab.id)"
>
<div class="tab-content">
<h4>{{ tab.title }}</h4>
<p>{{ tab.content }}</p>
<div class="tab-actions">
<Button label="儲存" icon="pi pi-save" class="p-button-success" />
<Button label="關閉" icon="pi pi-times" @click="closeTab(tab.id)" />
</div>
</div>
</TabPanel>
<TabPanel header="+" @tab-click="addNewTab">
<div class="tab-content">
<p>點擊 "+" 新增分頁</p>
</div>
</TabPanel>
</TabView>
</div>
<!-- 帶圖示的分頁 */
<div class="tab-section">
<h3>圖示分頁</h3>
<TabView>
<TabPanel>
<template #header>
<i class="pi pi-chart-bar"></i>
<span>統計報表</span>
</template>
<div class="tab-content">
<div class="stats-dashboard">
<div class="stat-card" v-for="stat in dashboardStats" :key="stat.title">
<div class="stat-icon" :style="{ backgroundColor: stat.color }">
<i :class="stat.icon"></i>
</div>
<div class="stat-details">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div>
</div>
</div>
</div>
</div>
</TabPanel>
<TabPanel>
<template #header>
<i class="pi pi-calendar"></i>
<span>行事曆</span>
</template>
<div class="tab-content">
<Calendar v-model="calendarDate" :inline="true" />
</div>
</TabPanel>
<TabPanel>
<template #header>
<i class="pi pi-cog"></i>
<span>系統設定</span>
</template>
<div class="tab-content">
<div class="system-settings">
<div class="setting-group">
<h5>一般設定</h5>
<div class="setting-item">
<label>啟用通知</label>
<Checkbox v-model="systemSettings.notifications" :binary="true" />
</div>
<div class="setting-item">
<label>自動備份</label>
<Checkbox v-model="systemSettings.autoBackup" :binary="true" />
</div>
</div>
<div class="setting-group">
<h5>效能設定</h5>
<div class="setting-item">
<label>快取大小 (MB)</label>
<Slider v-model="systemSettings.cacheSize" :min="100" :max="1000" />
<span>{{ systemSettings.cacheSize }} MB</span>
</div>
</div>
</div>
</div>
</TabPanel>
</TabView>
</div>
<!-- 可控制的分頁 -->
<div class="tab-section">
<h3>程式控制分頁</h3>
<div class="tab-controls">
<Button
v-for="(control, index) in tabControls"
:key="index"
:label="control.label"
@click="activeTabIndex = index"
:class="{ 'p-button-outlined': activeTabIndex !== index }"
/>
</div>
<TabView :activeIndex="activeTabIndex" @tab-change="onTabChange">
<TabPanel
v-for="control in tabControls"
:key="control.id"
:header="control.header"
>
<div class="tab-content">
<component :is="control.component" v-bind="control.props" />
</div>
</TabPanel>
</TabView>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Calendar from 'primevue/calendar'
import Dropdown from 'primevue/dropdown'
import Checkbox from 'primevue/checkbox'
import Slider from 'primevue/slider'
export default {
components: {
TabView,
TabPanel,
Calendar,
Dropdown,
Checkbox,
Slider
},
setup() {
const profile = ref({
name: '張小明',
email: 'zhang@example.com',
phone: '0912-345-678',
address: '台北市信義區'
})
const preferences = ref({
language: 'zh-TW',
timezone: 'Asia/Taipei',
theme: 'light'
})
const languages = ref([
{ name: '繁體中文', code: 'zh-TW' },
{ name: 'English', code: 'en-US' },
{ name: '日本語', code: 'ja-JP' }
])
const timezones = ref([
{ name: '台北時間', value: 'Asia/Taipei' },
{ name: '東京時間', value: 'Asia/Tokyo' },
{ name: '洛杉磯時間', value: 'America/Los_Angeles' }
])
const themes = ref([
{ name: '淺色主題', value: 'light' },
{ name: '深色主題', value: 'dark' },
{ name: '自動', value: 'auto' }
])
const closableTabs = ref([
{
id: 1,
title: '文件 1',
content: '這是第一個可關閉的分頁內容。',
closable: true
},
{
id: 2,
title: '文件 2',
content: '這是第二個可關閉的分頁內容。',
closable: true
},
{
id: 3,
title: '文件 3',
content: '這是第三個可關閉的分頁內容。',
closable: true
}
])
const dashboardStats = ref([
{ title: '今日訪客', value: '1,234', icon: 'pi pi-users', color: '#3B82F6' },
{ title: '銷售額', value: '$5,678', icon: 'pi pi-dollar', color: '#10B981' },
{ title: '訂單數', value: '89', icon: 'pi pi-shopping-cart', color: '#F59E0B' },
{ title: '轉換率', value: '2.4%', icon: 'pi pi-percentage', color: '#8B5CF6' }
])
const calendarDate = ref(new Date())
const systemSettings = ref({
notifications: true,
autoBackup: false,
cacheSize: 256
})
const activeTabIndex = ref(0)
const tabControls = ref([
{
id: 'dashboard',
label: '儀表板',
header: '數據儀表板',
component: 'div',
props: { innerHTML: '<h4>歡迎來到儀表板</h4><p>這裡顯示系統的主要數據統計。</p>' }
},
{
id: 'users',
label: '使用者',
header: '使用者管理',
component: 'div',
props: { innerHTML: '<h4>使用者管理</h4><p>管理系統中的所有使用者帳戶。</p>' }
},
{
id: 'reports',
label: '報表',
header: '報表中心',
component: 'div',
props: { innerHTML: '<h4>報表中心</h4><p>查看和產生各種業務報表。</p>' }
}
])
const closeTab = (tabId) => {
const index = closableTabs.value.findIndex(tab => tab.id === tabId)
if (index > -1) {
closableTabs.value.splice(index, 1)
}
}
const addNewTab = () => {
const newId = Math.max(...closableTabs.value.map(t => t.id)) + 1
closableTabs.value.push({
id: newId,
title: `文件 ${newId}`,
content: `這是新增的第 ${newId} 個分頁內容。`,
closable: true
})
}
const onTabChange = (event) => {
activeTabIndex.value = event.index
}
return {
profile,
preferences,
languages,
timezones,
themes,
closableTabs,
dashboardStats,
calendarDate,
systemSettings,
activeTabIndex,
tabControls,
closeTab,
addNewTab,
onTabChange
}
}
}
</script>
<style scoped>
.tabview-examples {
padding: 1rem;
}
.tab-section {
margin-bottom: 3rem;
}
.tab-section h3 {
margin-bottom: 1rem;
color: #495057;
}
.tab-content {
padding: 1rem 0;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
}
.field label {
margin-bottom: 0.5rem;
font-weight: 500;
}
.settings-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.setting-info h5 {
margin-bottom: 0.25rem;
color: #495057;
}
.setting-info p {
color: #6c757d;
font-size: 0.875rem;
margin: 0;
}
.preference-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.preference-item {
display: flex;
flex-direction: column;
}
.preference-item label {
margin-bottom: 0.5rem;
font-weight: 500;
}
.tab-actions {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
.stats-dashboard {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: white;
border: 1px solid #e9ecef;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.stat-title {
color: #6c757d;
font-size: 0.875rem;
}
.system-settings {
max-width: 500px;
}
.setting-group {
margin-bottom: 2rem;
}
.setting-group h5 {
margin-bottom: 1rem;
color: #495057;
border-bottom: 1px solid #e9ecef;
padding-bottom: 0.5rem;
}
.setting-group .setting-item {
justify-content: space-between;
padding: 0.75rem 0;
border: none;
border-bottom: 1px solid #f8f9fa;
}
.tab-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.preference-grid {
grid-template-columns: 1fr;
}
.stats-dashboard {
grid-template-columns: 1fr;
}
.setting-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.tab-controls {
flex-direction: column;
}
}
</style>
🪗 Accordion 手風琴
Accordion 提供可摺疊的面板組,適合展示分層級的內容。
<template>
<div class="accordion-examples">
<!-- 基本手風琴 -->
<div class="accordion-section">
<h3>常見問題 (FAQ)</h3>
<Accordion :activeIndex="0">
<AccordionTab header="如何建立帳戶?">
<p>您可以透過以下步驟建立帳戶:</p>
<ol>
<li>點擊頁面右上角的「註冊」按鈕</li>
<li>填寫必要的個人資訊</li>
<li>驗證您的電子郵件地址</li>
<li>設定安全的密碼</li>
<li>完成帳戶設定</li>
</ol>
</AccordionTab>
<AccordionTab header="如何重設密碼?">
<p>如果您忘記密碼,可以按照以下步驟重設:</p>
<ol>
<li>在登入頁面點擊「忘記密碼」</li>
<li>輸入您註冊時使用的電子郵件</li>
<li>檢查郵箱中的重設連結</li>
<li>點擊連結並設定新密碼</li>
</ol>
<div class="note">
<i class="pi pi-info-circle"></i>
<span>重設連結將在 24 小時後失效</span>
</div>
</AccordionTab>
<AccordionTab header="支援哪些付款方式?">
<p>我們支援多種付款方式:</p>
<div class="payment-methods">
<div class="payment-item">
<i class="pi pi-credit-card"></i>
<span>信用卡 (Visa, MasterCard, JCB)</span>
</div>
<div class="payment-item">
<i class="pi pi-paypal"></i>
<span>PayPal</span>
</div>
<div class="payment-item">
<i class="pi pi-building"></i>
<span>銀行轉帳</span>
</div>
<div class="payment-item">
<i class="pi pi-mobile"></i>
<span>行動支付 (Apple Pay, Google Pay)</span>
</div>
</div>
</AccordionTab>
<AccordionTab header="如何聯繫客服?">
<p>如果您需要協助,可以透過以下方式聯繫我們:</p>
<div class="contact-methods">
<div class="contact-item">
<i class="pi pi-phone"></i>
<div>
<strong>電話客服</strong>
<p>0800-123-456 (週一至週五 9:00-18:00)</p>
</div>
</div>
<div class="contact-item">
<i class="pi pi-envelope"></i>
<div>
<strong>電子郵件</strong>
<p>support@example.com</p>
</div>
</div>
<div class="contact-item">
<i class="pi pi-comments"></i>
<div>
<strong>線上客服</strong>
<p>週一至週日 24 小時服務</p>
</div>
</div>
</div>
</AccordionTab>
</Accordion>
</div>
<!-- 多選手風琴 -->
<div class="accordion-section">
<h3>產品功能介紹</h3>
<Accordion :multiple="true" :activeIndex="[0, 1]">
<AccordionTab header="🚀 核心功能">
<div class="feature-content">
<h4>強大的核心功能</h4>
<div class="feature-list">
<div class="feature-item">
<i class="pi pi-check-circle"></i>
<div>
<strong>高效能處理</strong>
<p>採用最新技術,提供極速的處理體驗</p>
</div>
</div>
<div class="feature-item">
<i class="pi pi-check-circle"></i>
<div>
<strong>智能分析</strong>
<p>AI 驅動的數據分析,洞察業務趨勢</p>
</div>
</div>
<div class="feature-item">
<i class="pi pi-check-circle"></i>
<div>
<strong>即時同步</strong>
<p>多設備間的即時數據同步</p>
</div>
</div>
</div>
</div>
</AccordionTab>
<AccordionTab header="🔒 安全防護">
<div class="security-content">
<h4>企業級安全保護</h4>
<div class="security-grid">
<div class="security-item">
<i class="pi pi-shield"></i>
<h5>資料加密</h5>
<p>採用 AES-256 加密技術</p>
</div>
<div class="security-item">
<i class="pi pi-lock"></i>
<h5>雙重認證</h5>
<p>多層次身份驗證機制</p>
</div>
<div class="security-item">
<i class="pi pi-eye"></i>
<h5>活動監控</h5>
<p>24/7 安全監控系統</p>
</div>
<div class="security-item">
<i class="pi pi-database"></i>
<h5>備份機制</h5>
<p>自動備份與災難復原</p>
</div>
</div>
</div>
</AccordionTab>
<AccordionTab header="📊 報表分析">
<div class="analytics-content">
<h4>深度數據洞察</h4>
<p>提供全方位的業務分析工具,協助您做出明智決策。</p>
<div class="chart-preview">
<div class="chart-item">
<h5>銷售趨勢</h5>
<div class="mini-chart">
<div class="chart-bar" style="height: 60%"></div>
<div class="chart-bar" style="height: 80%"></div>
<div class="chart-bar" style="height: 45%"></div>
<div class="chart-bar" style="height: 90%"></div>
<div class="chart-bar" style="height: 70%"></div>
</div>
</div>
<div class="chart-item">
<h5>使用者活躍度</h5>
<div class="progress-ring">
<div class="progress-value">78%</div>
</div>
</div>
</div>
</div>
</AccordionTab>
<AccordionTab header="🌐 整合服務">
<div class="integration-content">
<h4>無縫第三方整合</h4>
<div class="integration-grid">
<div class="integration-item">
<img src="https://via.placeholder.com/40x40?text=G" alt="Google" />
<span>Google Workspace</span>
</div>
<div class="integration-item">
<img src="https://via.placeholder.com/40x40?text=M" alt="Microsoft" />
<span>Microsoft 365</span>
</div>
<div class="integration-item">
<img src="https://via.placeholder.com/40x40?text=S" alt="Slack" />
<span>Slack</span>
</div>
<div class="integration-item">
<img src="https://via.placeholder.com/40x40?text=Z" alt="Zoom" />
<span>Zoom</span>
</div>
<div class="integration-item">
<img src="https://via.placeholder.com/40x40?text=D" alt="Dropbox" />
<span>Dropbox</span>
</div>
<div class="integration-item">
<img src="https://via.placeholder.com/40x40?text=T" alt="Trello" />
<span>Trello</span>
</div>
</div>
</div>
</AccordionTab>
</Accordion>
</div>
<!-- 自訂樣式手風琴 -->
<div class="accordion-section">
<h3>專案時程表</h3>
<Accordion class="timeline-accordion">
<AccordionTab v-for="phase in projectPhases" :key="phase.id">
<template #header>
<div class="phase-header">
<div class="phase-status" :class="phase.status"></div>
<div class="phase-info">
<span class="phase-title">{{ phase.title }}</span>
<span class="phase-date">{{ phase.date }}</span>
</div>
</div>
</template>
<div class="phase-content">
<p>{{ phase.description }}</p>
<div class="task-list">
<div v-for="task in phase.tasks" :key="task.id" class="task-item">
<Checkbox v-model="task.completed" :binary="true" />
<span :class="{ 'completed': task.completed }">{{ task.name }}</span>
</div>
</div>
<div class="phase-progress">
<label>完成進度</label>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: phase.progress + '%' }"></div>
</div>
<span>{{ phase.progress }}%</span>
</div>
</div>
</AccordionTab>
</Accordion>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import Accordion from 'primevue/accordion'
import AccordionTab from 'primevue/accordiontab'
import Checkbox from 'primevue/checkbox'
export default {
components: {
Accordion,
AccordionTab,
Checkbox
},
setup() {
const projectPhases = ref([
{
id: 1,
title: '專案規劃',
date: '2024-01-01 ~ 2024-01-15',
status: 'completed',
progress: 100,
description: '定義專案範圍、目標和時程安排。',
tasks: [
{ id: 1, name: '需求分析', completed: true },
{ id: 2, name: '技術評估', completed: true },
{ id: 3, name: '資源配置', completed: true }
]
},
{
id: 2,
title: '系統設計',
date: '2024-01-16 ~ 2024-02-15',
status: 'completed',
progress: 100,
description: '完成系統架構設計和 UI/UX 設計。',
tasks: [
{ id: 4, name: '架構設計', completed: true },
{ id: 5, name: 'UI 設計', completed: true },
{ id: 6, name: '資料庫設計', completed: true }
]
},
{
id: 3,
title: '開發實作',
date: '2024-02-16 ~ 2024-04-30',
status: 'in-progress',
progress: 65,
description: '進行系統開發和功能實作。',
tasks: [
{ id: 7, name: '前端開發', completed: true },
{ id: 8, name: '後端開發', completed: false },
{ id: 9, name: 'API 整合', completed: false }
]
},
{
id: 4,
title: '測試驗證',
date: '2024-05-01 ~ 2024-05-31',
status: 'pending',
progress: 0,
description: '執行全面的系統測試和使用者驗收測試。',
tasks: [
{ id: 10, name: '單元測試', completed: false },
{ id: 11, name: '整合測試', completed: false },
{ id: 12, name: '使用者測試', completed: false }
]
},
{
id: 5,
title: '部署上線',
date: '2024-06-01 ~ 2024-06-15',
status: 'pending',
progress: 0,
description: '部署系統到生產環境並進行上線準備。',
tasks: [
{ id: 13, name: '環境部署', completed: false },
{ id: 14, name: '資料遷移', completed: false },
{ id: 15, name: '上線驗證', completed: false }
]
}
])
return {
projectPhases
}
}
}
</script>
<style scoped>
.accordion-examples {
padding: 1rem;
}
.accordion-section {
margin-bottom: 3rem;
}
.accordion-section h3 {
margin-bottom: 1rem;
color: #495057;
}
.note {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.75rem;
background: #e3f2fd;
border-radius: 4px;
color: #1976d2;
}
.payment-methods,
.contact-methods {
margin-top: 1rem;
}
.payment-item,
.contact-item {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
border-radius: 4px;
background: #f8f9fa;
}
.payment-item i,
.contact-item i {
font-size: 1.25rem;
color: #007ad9;
}
.contact-item {
align-items: flex-start;
}
.contact-item div strong {
display: block;
margin-bottom: 0.25rem;
}
.contact-item div p {
margin: 0;
color: #6c757d;
}
.feature-content h4,
.security-content h4,
.analytics-content h4,
.integration-content h4 {
margin-bottom: 1rem;
color: #495057;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.feature-item i {
color: #198754;
margin-top: 0.25rem;
}
.feature-item strong {
display: block;
margin-bottom: 0.25rem;
}
.feature-item p {
margin: 0;
color: #6c757d;
}
.security-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.security-item {
text-align: center;
padding: 1rem;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.security-item i {
font-size: 2rem;
color: #007ad9;
margin-bottom: 0.5rem;
}
.security-item h5 {
margin-bottom: 0.5rem;
color: #495057;
}
.security-item p {
margin: 0;
color: #6c757d;
font-size: 0.875rem;
}
.chart-preview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-top: 1rem;
}
.chart-item {
text-align: center;
}
.chart-item h5 {
margin-bottom: 1rem;
color: #495057;
}
.mini-chart {
display: flex;
align-items: end;
justify-content: center;
gap: 4px;
height: 60px;
}
.chart-bar {
width: 12px;
background: linear-gradient(to top, #007ad9, #64b5f6);
border-radius: 2px;
}
.progress-ring {
width: 80px;
height: 80px;
border-radius: 50%;
background: conic-gradient(#007ad9 0deg 280deg, #e9ecef 280deg);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
position: relative;
}
.progress-ring::before {
content: '';
width: 60px;
height: 60px;
background: white;
border-radius: 50%;
position: absolute;
}
.progress-value {
position: relative;
z-index: 1;
font-weight: bold;
color: #007ad9;
}
.integration-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.integration-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #e9ecef;
border-radius: 6px;
transition: border-color 0.2s;
}
.integration-item:hover {
border-color: #007ad9;
}
.integration-item img {
width: 32px;
height: 32px;
border-radius: 4px;
}
.timeline-accordion .phase-header {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
}
.phase-status {
width: 12px;
height: 12px;
border-radius: 50%;
}
.phase-status.completed {
background: #198754;
}
.phase-status.in-progress {
background: #fd7e14;
}
.phase-status.pending {
background: #6c757d;
}
.phase-info {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.phase-title {
font-weight: 600;
}
.phase-date {
font-size: 0.875rem;
color: #6c757d;
}
.phase-content {
padding: 1rem 0;
}
.task-list {
margin: 1rem 0;
}
.task-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.task-item .completed {
text-decoration: line-through;
color: #6c757d;
}
.phase-progress {
margin-top: 1rem;
}
.phase-progress label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: #007ad9;
transition: width 0.3s ease;
}
@media (max-width: 768px) {
.security-grid {
grid-template-columns: 1fr;
}
.chart-preview {
grid-template-columns: 1fr;
}
.integration-grid {
grid-template-columns: 1fr;
}
.phase-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>
2.4 對話框與通知(Dialog、Toast、ConfirmDialog)
💬 Dialog 對話框
Dialog 提供模態和非模態的對話框功能,用於顯示重要訊息或收集使用者輸入。
<template>
<div class="dialog-examples">
<!-- 觸發按鈕 -->
<div class="dialog-triggers">
<h3>對話框範例</h3>
<div class="button-group">
<Button
label="基本對話框"
icon="pi pi-external-link"
@click="showBasicDialog = true"
/>
<Button
label="表單對話框"
icon="pi pi-user-edit"
@click="showFormDialog = true"
/>
<Button
label="確認對話框"
icon="pi pi-question-circle"
@click="showConfirmDialog = true"
/>
<Button
label="全螢幕對話框"
icon="pi pi-window-maximize"
@click="showFullscreenDialog = true"
/>
<Button
label="可拖曳對話框"
icon="pi pi-arrows-alt"
@click="showDraggableDialog = true"
/>
</div>
</div>
<!-- 基本對話框 -->
<Dialog
v-model:visible="showBasicDialog"
modal
header="產品資訊"
:style="{ width: '450px' }"
>
<div class="dialog-content">
<div class="product-info">
<img src="https://via.placeholder.com/300x200?text=Product" alt="產品圖片" class="product-image" />
<h4>iPhone 14 Pro</h4>
<p class="product-description">
配備專業級相機系統的 iPhone 14 Pro,採用 A16 仿生晶片,
提供卓越的效能與攝影體驗。
</p>
<div class="product-price">
<span class="price">$999</span>
<span class="original-price">$1099</span>
</div>
<div class="product-features">
<div class="feature">
<i class="pi pi-check-circle"></i>
<span>專業相機系統</span>
</div>
<div class="feature">
<i class="pi pi-check-circle"></i>
<span>A16 仿生晶片</span>
</div>
<div class="feature">
<i class="pi pi-check-circle"></i>
<span>全天候電池續航</span>
</div>
</div>
</div>
</div>
<template #footer>
<Button
label="加入購物車"
icon="pi pi-shopping-cart"
@click="addToCart"
class="p-button-success"
/>
<Button
label="關閉"
icon="pi pi-times"
@click="showBasicDialog = false"
class="p-button-text"
/>
</template>
</Dialog>
<!-- 表單對話框 -->
<Dialog
v-model:visible="showFormDialog"
modal
header="新增使用者"
:style="{ width: '500px' }"
>
<div class="dialog-content">
<form @submit.prevent="submitForm" class="user-form">
<div class="field">
<label for="username">使用者名稱 *</label>
<InputText
id="username"
v-model="newUser.username"
:class="{ 'p-invalid': submitted && !newUser.username }"
placeholder="請輸入使用者名稱"
/>
<small v-if="submitted && !newUser.username" class="p-error">使用者名稱為必填欄位</small>
</div>
<div class="field">
<label for="email">Email *</label>
<InputText
id="email"
v-model="newUser.email"
type="email"
:class="{ 'p-invalid': submitted && !newUser.email }"
placeholder="請輸入 Email"
/>
<small v-if="submitted && !newUser.email" class="p-error">Email 為必填欄位</small>
</div>
<div class="field">
<label for="role">角色</label>
<Dropdown
id="role"
v-model="newUser.role"
:options="roles"
optionLabel="name"
optionValue="value"
placeholder="請選擇角色"
/>
</div>
<div class="field">
<label for="department">部門</label>
<Dropdown
id="department"
v-model="newUser.department"
:options="departments"
optionLabel="name"
optionValue="value"
placeholder="請選擇部門"
/>
</div>
<div class="field checkbox-field">
<Checkbox id="active" v-model="newUser.active" :binary="true" />
<label for="active">帳戶啟用</label>
</div>
</form>
</div>
<template #footer>
<Button
label="儲存"
icon="pi pi-check"
@click="submitForm"
class="p-button-success"
/>
<Button
label="取消"
icon="pi pi-times"
@click="cancelForm"
class="p-button-text"
/>
</template>
</Dialog>
<!-- 其他對話框省略以節省篇幅... -->
</div>
</template>
<script>
import { ref } from 'vue'
import Dialog from 'primevue/dialog'
import Dropdown from 'primevue/dropdown'
import Checkbox from 'primevue/checkbox'
export default {
components: {
Dialog,
Dropdown,
Checkbox
},
setup() {
const showBasicDialog = ref(false)
const showFormDialog = ref(false)
const submitted = ref(false)
const newUser = ref({
username: '',
email: '',
role: null,
department: null,
active: true
})
const roles = ref([
{ name: '管理員', value: 'admin' },
{ name: '編輯者', value: 'editor' },
{ name: '檢視者', value: 'viewer' }
])
const departments = ref([
{ name: '工程部', value: 'engineering' },
{ name: '設計部', value: 'design' },
{ name: '產品部', value: 'product' },
{ name: '行銷部', value: 'marketing' }
])
const addToCart = () => {
console.log('加入購物車')
showBasicDialog.value = false
}
const submitForm = () => {
submitted.value = true
if (newUser.value.username && newUser.value.email) {
console.log('新增使用者:', newUser.value)
showFormDialog.value = false
submitted.value = false
// 重設表單
newUser.value = {
username: '',
email: '',
role: null,
department: null,
active: true
}
}
}
const cancelForm = () => {
showFormDialog.value = false
submitted.value = false
newUser.value = {
username: '',
email: '',
role: null,
department: null,
active: true
}
}
return {
showBasicDialog,
showFormDialog,
submitted,
newUser,
roles,
departments,
addToCart,
submitForm,
cancelForm
}
}
}
</script>
<style scoped>
.dialog-examples {
padding: 1rem;
}
.dialog-triggers h3 {
margin-bottom: 1rem;
color: #495057;
}
.button-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.dialog-content {
padding: 1rem 0;
}
.product-info {
text-align: center;
}
.product-image {
width: 100%;
max-width: 300px;
border-radius: 8px;
margin-bottom: 1rem;
}
.user-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
}
.field label {
margin-bottom: 0.5rem;
font-weight: 500;
}
.checkbox-field {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.p-error {
color: #e24c4c;
font-size: 0.875rem;
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.button-group {
flex-direction: column;
}
}
</style>
🔔 Toast 通知
Toast 提供非侵入式的通知訊息,用於向使用者顯示操作結果或重要訊息。
<template>
<div class="toast-examples">
<h3>Toast 通知範例</h3>
<!-- 觸發按鈕 -->
<div class="toast-triggers">
<div class="severity-group">
<h4>不同嚴重程度</h4>
<div class="button-group">
<Button
label="成功訊息"
icon="pi pi-check"
@click="showSuccess"
class="p-button-success"
/>
<Button
label="資訊訊息"
icon="pi pi-info-circle"
@click="showInfo"
class="p-button-info"
/>
<Button
label="警告訊息"
icon="pi pi-exclamation-triangle"
@click="showWarn"
class="p-button-warning"
/>
<Button
label="錯誤訊息"
icon="pi pi-times-circle"
@click="showError"
class="p-button-danger"
/>
</div>
</div>
<div class="position-group">
<h4>不同位置</h4>
<div class="button-group">
<Button label="右上角" @click="showTopRight" class="p-button-outlined" />
<Button label="右下角" @click="showBottomRight" class="p-button-outlined" />
<Button label="左上角" @click="showTopLeft" class="p-button-outlined" />
<Button label="左下角" @click="showBottomLeft" class="p-button-outlined" />
<Button label="中央" @click="showCenter" class="p-button-outlined" />
</div>
</div>
</div>
<!-- Toast 元件 -->
<Toast />
<Toast position="top-left" group="tl" />
<Toast position="bottom-left" group="bl" />
<Toast position="bottom-right" group="br" />
<Toast position="center" group="c" />
</div>
</template>
<script>
import { useToast } from 'primevue/usetoast'
export default {
setup() {
const toast = useToast()
const showSuccess = () => {
toast.add({
severity: 'success',
summary: '操作成功',
detail: '您的操作已成功完成!',
life: 3000
})
}
const showInfo = () => {
toast.add({
severity: 'info',
summary: '系統資訊',
detail: '這是一個資訊性的通知訊息。',
life: 3000
})
}
const showWarn = () => {
toast.add({
severity: 'warn',
summary: '注意警告',
detail: '請注意,這個操作可能會有風險。',
life: 4000
})
}
const showError = () => {
toast.add({
severity: 'error',
summary: '錯誤發生',
detail: '操作失敗,請檢查輸入內容並重試。',
life: 5000
})
}
const showTopRight = () => {
toast.add({
severity: 'info',
summary: '右上角訊息',
detail: '這是顯示在右上角的通知',
life: 3000
})
}
const showTopLeft = () => {
toast.add({
severity: 'info',
summary: '左上角訊息',
detail: '這是顯示在左上角的通知',
life: 3000,
group: 'tl'
})
}
const showBottomRight = () => {
toast.add({
severity: 'info',
summary: '右下角訊息',
detail: '這是顯示在右下角的通知',
life: 3000,
group: 'br'
})
}
const showBottomLeft = () => {
toast.add({
severity: 'info',
summary: '左下角訊息',
detail: '這是顯示在左下角的通知',
life: 3000,
group: 'bl'
})
}
const showCenter = () => {
toast.add({
severity: 'info',
summary: '中央訊息',
detail: '這是顯示在畫面中央的通知',
life: 3000,
group: 'c'
})
}
return {
showSuccess,
showInfo,
showWarn,
showError,
showTopRight,
showTopLeft,
showBottomRight,
showBottomLeft,
showCenter
}
}
}
</script>
</template>
<style scoped>
.toast-examples {
padding: 1rem;
}
.toast-triggers {
display: flex;
flex-direction: column;
gap: 2rem;
}
.severity-group,
.position-group {
padding: 1.5rem;
border: 1px solid #e9ecef;
border-radius: 8px;
}
.button-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.button-group {
flex-direction: column;
}
}
</style>
❓ ConfirmDialog 確認對話框
ConfirmDialog 提供統一的確認對話框服務,用於需要使用者確認的操作。
<template>
<div class="confirm-dialog-examples">
<h3>確認對話框範例</h3>
<div class="confirm-triggers">
<div class="basic-confirms">
<h4>基本確認對話框</h4>
<div class="button-group">
<Button
label="刪除項目"
icon="pi pi-trash"
@click="confirmDelete"
class="p-button-danger"
/>
<Button
label="儲存變更"
icon="pi pi-save"
@click="confirmSave"
class="p-button-success"
/>
<Button
label="離開頁面"
icon="pi pi-sign-out"
@click="confirmLeave"
class="p-button-warning"
/>
</div>
</div>
</div>
<!-- 確認對話框元件 -->
<ConfirmDialog />
<Toast />
</div>
</template>
<script>
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import ConfirmDialog from 'primevue/confirmdialog'
export default {
components: {
ConfirmDialog
},
setup() {
const confirm = useConfirm()
const toast = useToast()
const confirmDelete = () => {
confirm.require({
message: '確定要刪除此項目嗎?此操作無法復原。',
header: '確認刪除',
icon: 'pi pi-exclamation-triangle',
accept: () => {
toast.add({
severity: 'success',
summary: '刪除成功',
detail: '項目已成功刪除',
life: 3000
})
},
reject: () => {
toast.add({
severity: 'info',
summary: '已取消',
detail: '刪除操作已取消',
life: 2000
})
}
})
}
const confirmSave = () => {
confirm.require({
message: '確定要儲存目前的變更嗎?',
header: '儲存確認',
icon: 'pi pi-question-circle',
accept: () => {
toast.add({
severity: 'success',
summary: '儲存成功',
detail: '您的變更已成功儲存',
life: 3000
})
}
})
}
const confirmLeave = () => {
confirm.require({
message: '您有未儲存的變更,確定要離開此頁面嗎?',
header: '離開確認',
icon: 'pi pi-exclamation-triangle',
accept: () => {
toast.add({
severity: 'info',
summary: '已離開',
detail: '正在導向新頁面...',
life: 2000
})
}
})
}
return {
confirmDelete,
confirmSave,
confirmLeave
}
}
}
</script>
</template>
<style scoped>
.confirm-dialog-examples {
padding: 1rem;
}
.confirm-triggers {
display: flex;
flex-direction: column;
gap: 2rem;
}
.basic-confirms {
padding: 1.5rem;
border: 1px solid #e9ecef;
border-radius: 8px;
}
.button-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
</style>
2.5 版面配置元件(Panel、Card、Divider、Splitter)
📋 Panel 面板
Panel 提供可折疊的內容容器,適合組織相關的內容區塊。
<template>
<div class="panel-examples">
<h3>Panel 面板範例</h3>
<!-- 基本面板 -->
<Panel header="基本面板" class="mb-4">
<p>這是面板的內容區域。您可以在這裡放置任何內容,包括文字、圖片、表單等。</p>
</Panel>
<!-- 可折疊面板 -->
<Panel header="可折疊面板" :toggleable="true" class="mb-4">
<p>這個面板可以折疊和展開。點擊標題旁的圖示來切換顯示狀態。</p>
<div class="panel-content">
<h5>面板功能特色:</h5>
<ul>
<li>支援自訂標題</li>
<li>可折疊/展開內容</li>
<li>靈活的內容組織</li>
<li>響應式設計</li>
</ul>
</div>
</Panel>
<!-- 自訂標題面板 -->
<Panel :toggleable="true" class="mb-4">
<template #header>
<div class="custom-header">
<i class="pi pi-user"></i>
<span>使用者資訊</span>
<Badge value="新" severity="success" class="ml-2" />
</div>
</template>
<div class="user-info">
<img src="https://via.placeholder.com/80?text=Avatar" alt="頭像" class="user-avatar" />
<div class="user-details">
<h4>張小明</h4>
<p>前端工程師</p>
<p>📧 zhang@example.com</p>
<p>📱 +886-912-345-678</p>
</div>
</div>
</Panel>
</div>
</template>
<script>
import Panel from 'primevue/panel'
import Badge from 'primevue/badge'
export default {
components: {
Panel,
Badge
}
}
</script>
<style scoped>
.panel-examples {
padding: 1rem;
}
.custom-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-info {
display: flex;
gap: 1rem;
align-items: center;
}
.user-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
}
.user-details h4 {
margin-bottom: 0.5rem;
}
.user-details p {
margin-bottom: 0.25rem;
color: #6c757d;
}
</style>
🃏 Card 卡片
Card 提供有陰影效果的內容容器,適合顯示結構化的資訊。
<template>
<div class="card-examples">
<h3>Card 卡片範例</h3>
<div class="card-grid">
<!-- 基本卡片 -->
<Card class="basic-card">
<template #title>產品標題</template>
<template #content>
<p>這是卡片的內容區域,可以放置各種資訊和元件。</p>
</template>
</Card>
<!-- 帶圖片的卡片 -->
<Card class="image-card">
<template #header>
<img src="https://via.placeholder.com/300x200?text=Header+Image" alt="卡片圖片" />
</template>
<template #title>美食推薦</template>
<template #subtitle>台北必吃美食</template>
<template #content>
<p>探索台北最道地的美食文化,從夜市小吃到精緻料理,帶您品嚐城市的味蕾故事。</p>
</template>
<template #footer>
<div class="card-footer">
<Button icon="pi pi-heart" class="p-button-outlined p-button-secondary" />
<Button label="了解更多" icon="pi pi-arrow-right" />
</div>
</template>
</Card>
<!-- 產品卡片 -->
<Card class="product-card">
<template #header>
<div class="product-badge">
<Badge value="限時優惠" severity="danger" />
</div>
<img src="https://via.placeholder.com/300x200?text=Product" alt="產品圖片" />
</template>
<template #title>無線藍牙耳機</template>
<template #subtitle>高品質音效體驗</template>
<template #content>
<div class="product-info">
<div class="price-section">
<span class="current-price">$1,299</span>
<span class="original-price">$1,899</span>
<span class="discount">32% off</span>
</div>
<div class="rating">
<i class="pi pi-star-fill"></i>
<i class="pi pi-star-fill"></i>
<i class="pi pi-star-fill"></i>
<i class="pi pi-star-fill"></i>
<i class="pi pi-star"></i>
<span class="rating-text">(4.2)</span>
</div>
<div class="features">
<div class="feature">
<i class="pi pi-check"></i>
<span>降噪功能</span>
</div>
<div class="feature">
<i class="pi pi-check"></i>
<span>30小時續航</span>
</div>
<div class="feature">
<i class="pi pi-check"></i>
<span>快速充電</span>
</div>
</div>
</div>
</template>
<template #footer>
<div class="product-actions">
<Button label="加入購物車" icon="pi pi-shopping-cart" class="p-button-success" />
<Button icon="pi pi-heart" class="p-button-outlined" />
</div>
</template>
</Card>
<!-- 統計卡片 -->
<Card class="stats-card">
<template #content>
<div class="stats-content">
<div class="stats-icon">
<i class="pi pi-users"></i>
</div>
<div class="stats-info">
<h3>1,234</h3>
<p>活躍用戶</p>
<div class="stats-trend positive">
<i class="pi pi-arrow-up"></i>
<span>+12%</span>
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>
<script>
import Card from 'primevue/card'
import Badge from 'primevue/badge'
export default {
components: {
Card,
Badge
}
}
</script>
<style scoped>
.card-examples {
padding: 1rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 1rem;
}
.basic-card,
.image-card,
.product-card {
height: fit-content;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-badge {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 1;
}
.product-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.price-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.current-price {
font-size: 1.5rem;
font-weight: bold;
color: #16a085;
}
.original-price {
text-decoration: line-through;
color: #7f8c8d;
}
.discount {
background: #e74c3c;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
.rating {
display: flex;
align-items: center;
gap: 0.25rem;
}
.rating i {
color: #f39c12;
}
.rating-text {
margin-left: 0.5rem;
color: #7f8c8d;
}
.features {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.feature {
display: flex;
align-items: center;
gap: 0.5rem;
}
.feature i {
color: #27ae60;
}
.product-actions {
display: flex;
gap: 0.5rem;
width: 100%;
}
.product-actions .p-button {
flex: 1;
}
.stats-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stats-content {
display: flex;
align-items: center;
gap: 1rem;
}
.stats-icon {
font-size: 3rem;
opacity: 0.8;
}
.stats-info h3 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.stats-info p {
margin-bottom: 0.5rem;
opacity: 0.9;
}
.stats-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
}
.stats-trend.positive {
color: #2ecc71;
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: 1fr;
}
.stats-content {
flex-direction: column;
text-align: center;
}
}
</style>
第二章 2.4-2.5 節實務注意事項
對話框設計原則:
- 保持內容簡潔明瞭
- 提供清楚的行動按鈕
- 適當的模態性選擇
- 響應式設計考量
Toast 使用建議:
- 根據訊息重要性選擇適當的嚴重程度
- 設定合理的顯示時間
- 避免同時顯示過多通知
- 提供適當的操作回饋
版面配置最佳實務:
- 合理使用 Panel 組織內容層次
- Card 適合展示獨立的資訊單元
- 注意不同元件間的視覺平衡
- 響應式設計的一致性
第二章核心元件總結
✅ 第二章檢查清單
按鈕元件掌握:
- ✅ 熟悉基本按鈕用法和樣式類型
- ✅ 能夠處理按鈕事件和狀態管理
- ✅ 了解載入狀態和禁用狀態的應用
表單元件應用:
- ✅ 掌握 InputText、Password、Dropdown 基本用法
- ✅ 理解 Checkbox 和 RadioButton 的資料綁定
- ✅ 能夠使用 Calendar 和 Slider 進行日期時間選擇
資料顯示精通:
- ✅ 熟練使用 DataTable 進行資料展示和操作
- ✅ 掌握 Listbox 的多選和自訂模板功能
- ✅ 靈活運用 Card、Panel、TabView、Accordion 組織內容
對話框與通知掌握:
- ✅ 掌握 Dialog 的各種使用場景和配置
- ✅ 熟悉 Toast 通知的不同嚴重程度和位置設定
- ✅ 能夠使用 ConfirmDialog 進行使用者操作確認
- ✅ 了解對話框的模態性和拖曳功能
版面配置能力:
- ✅ 熟練使用 Panel 組織內容結構
- ✅ 掌握 Card 的各種展示形式和自訂範本
- ✅ 能夠創建響應式的卡片佈局
- ✅ 理解不同版面元件的適用場景
實務應用能力:
- ✅ 能夠設計響應式的元件佈局
- ✅ 掌握元件間的資料傳遞和狀態管理
- ✅ 了解效能優化和無障礙支援的重要性
- ✅ 能夠組合多個元件創建完整的使用者介面
🎯 第二章重點回顧
核心元件生態系統:PrimeVue 提供了完整的 UI 元件庫,涵蓋表單輸入、資料展示、互動回饋等各個面向
一致性設計語言:所有元件遵循統一的設計規範,確保使用者體驗的一致性
靈活的客製化能力:透過 props、slots 和 CSS 變數,可以滿足各種客製化需求
響應式支援:內建的響應式特性,確保在不同裝置上的良好體驗
無障礙支援:遵循 WCAG 規範,提供完整的鍵盤導航和螢幕閱讀器支援
💡 進階學習建議
在掌握了第二章的核心元件後,建議:
- 實務練習:嘗試組合不同元件創建完整的頁面
- 效能優化:學習按需載入和虛擬滾動等技術
- 客製化深度探索:研究主題系統和 CSS 變數的進階用法
- 無障礙最佳實務:深入了解 ARIA 屬性和鍵盤導航
- 測試策略:學習如何測試複雜的 UI 元件互動
準備好進入第三章「專案應用實戰」了嗎?我們將學習如何在實際專案中運用這些元件!
第三章:專案應用實戰
3.1 建立完整的使用者管理系統
在本章中,我們將整合前面學習的所有元件,建立一個完整的使用者管理系統。這個實戰專案將涵蓋:
- 使用者列表展示
- 新增/編輯使用者表單
- 搜尋和篩選功能
- 批次操作
- 響應式設計
🎯 專案目標
創建一個功能完整的使用者管理系統,包含以下功能:
- 使用者列表:展示使用者資訊,支援排序、分頁、搜尋
- CRUD 操作:新增、檢視、編輯、刪除使用者
- 進階篩選:多條件篩選和快速搜尋
- 批次操作:選擇多個使用者進行批次操作
- 響應式介面:適配桌面和行動裝置
📋 專案結構規劃
src/
├── components/
│ ├── UserManagement/
│ │ ├── UserList.vue # 使用者列表
│ │ ├── UserForm.vue # 使用者表單
│ │ ├── UserDetail.vue # 使用者詳情
│ │ ├── UserFilters.vue # 篩選元件
│ │ └── UserActions.vue # 批次操作
│ └── Common/
│ ├── PageHeader.vue # 頁面標題
│ └── LoadingSpinner.vue # 載入動畫
├── views/
│ └── UserManagement.vue # 主頁面
├── stores/
│ └── userStore.js # 使用者狀態管理
├── services/
│ └── userService.js # API 服務
└── utils/
└── validators.js # 表單驗證
🚀 開始實作
1. 使用者資料模型
首先定義使用者資料結構:
// models/User.js
export const UserStatus = {
ACTIVE: 'active',
INACTIVE: 'inactive',
PENDING: 'pending',
SUSPENDED: 'suspended'
}
export const UserRole = {
ADMIN: 'admin',
MANAGER: 'manager',
USER: 'user',
GUEST: 'guest'
}
export const Department = {
ENGINEERING: 'engineering',
DESIGN: 'design',
MARKETING: 'marketing',
SALES: 'sales',
HR: 'hr'
}
export const UserModel = {
id: null,
username: '',
email: '',
firstName: '',
lastName: '',
phone: '',
role: UserRole.USER,
department: Department.ENGINEERING,
status: UserStatus.ACTIVE,
avatar: '',
joinDate: null,
lastLogin: null,
isEmailVerified: false,
permissions: [],
metadata: {}
}
2. 使用者狀態管理 (Pinia Store)
// stores/userStore.js
import { defineStore } from 'pinia'
import { userService } from '@/services/userService'
export const useUserStore = defineStore('user', {
state: () => ({
users: [],
loading: false,
error: null,
total: 0,
currentPage: 1,
pageSize: 10,
filters: {
search: '',
role: null,
department: null,
status: null
},
selectedUsers: [],
currentUser: null
}),
getters: {
filteredUsers: (state) => {
let result = [...state.users]
if (state.filters.search) {
const search = state.filters.search.toLowerCase()
result = result.filter(user =>
user.username.toLowerCase().includes(search) ||
user.email.toLowerCase().includes(search) ||
user.firstName.toLowerCase().includes(search) ||
user.lastName.toLowerCase().includes(search)
)
}
if (state.filters.role) {
result = result.filter(user => user.role === state.filters.role)
}
if (state.filters.department) {
result = result.filter(user => user.department === state.filters.department)
}
if (state.filters.status) {
result = result.filter(user => user.status === state.filters.status)
}
return result
},
hasSelection: (state) => state.selectedUsers.length > 0,
selectionCount: (state) => state.selectedUsers.length
},
actions: {
async fetchUsers(page = 1) {
this.loading = true
this.error = null
try {
const response = await userService.getUsers({
page,
pageSize: this.pageSize,
...this.filters
})
this.users = response.data
this.total = response.total
this.currentPage = page
} catch (error) {
this.error = error.message
console.error('Failed to fetch users:', error)
} finally {
this.loading = false
}
},
async createUser(userData) {
this.loading = true
this.error = null
try {
const newUser = await userService.createUser(userData)
this.users.unshift(newUser)
this.total++
return newUser
} catch (error) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async updateUser(id, userData) {
this.loading = true
this.error = null
try {
const updatedUser = await userService.updateUser(id, userData)
const index = this.users.findIndex(user => user.id === id)
if (index !== -1) {
this.users[index] = updatedUser
}
return updatedUser
} catch (error) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async deleteUser(id) {
this.loading = true
this.error = null
try {
await userService.deleteUser(id)
this.users = this.users.filter(user => user.id !== id)
this.selectedUsers = this.selectedUsers.filter(userId => userId !== id)
this.total--
} catch (error) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async batchDeleteUsers(userIds) {
this.loading = true
this.error = null
try {
await userService.batchDeleteUsers(userIds)
this.users = this.users.filter(user => !userIds.includes(user.id))
this.selectedUsers = []
this.total -= userIds.length
} catch (error) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
setFilters(filters) {
this.filters = { ...this.filters, ...filters }
this.fetchUsers(1) // 重設到第一頁
},
clearFilters() {
this.filters = {
search: '',
role: null,
department: null,
status: null
}
this.fetchUsers(1)
},
toggleUserSelection(userId) {
const index = this.selectedUsers.indexOf(userId)
if (index === -1) {
this.selectedUsers.push(userId)
} else {
this.selectedUsers.splice(index, 1)
}
},
selectAllUsers() {
this.selectedUsers = this.users.map(user => user.id)
},
clearSelection() {
this.selectedUsers = []
}
}
})
3. API 服務層
// services/userService.js
const API_BASE_URL = '/api'
class UserService {
async getUsers(params = {}) {
const queryString = new URLSearchParams(params).toString()
const response = await fetch(`${API_BASE_URL}/users?${queryString}`)
if (!response.ok) {
throw new Error('Failed to fetch users')
}
return response.json()
}
async getUserById(id) {
const response = await fetch(`${API_BASE_URL}/users/${id}`)
if (!response.ok) {
throw new Error('Failed to fetch user')
}
return response.json()
}
async createUser(userData) {
const response = await fetch(`${API_BASE_URL}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to create user')
}
return response.json()
}
async updateUser(id, userData) {
const response = await fetch(`${API_BASE_URL}/users/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to update user')
}
return response.json()
}
async deleteUser(id) {
const response = await fetch(`${API_BASE_URL}/users/${id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('Failed to delete user')
}
}
async batchDeleteUsers(userIds) {
const response = await fetch(`${API_BASE_URL}/users/batch-delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userIds })
})
if (!response.ok) {
throw new Error('Failed to delete users')
}
}
async exportUsers(filters = {}) {
const queryString = new URLSearchParams({
...filters,
export: true
}).toString()
const response = await fetch(`${API_BASE_URL}/users/export?${queryString}`)
if (!response.ok) {
throw new Error('Failed to export users')
}
return response.blob()
}
}
export const userService = new UserService()
4. 主要 Vue 元件實作
UserManagement.vue - 主頁面元件
<template>
<div class="user-management">
<!-- 頁面標題 -->
<div class="page-header">
<div class="header-content">
<div class="title-section">
<h1 class="page-title">
<i class="pi pi-users"></i>
使用者管理
</h1>
<p class="page-subtitle">管理系統使用者和權限設定</p>
</div>
<div class="header-actions">
<Button
label="匯出資料"
icon="pi pi-download"
@click="exportUsers"
class="p-button-outlined"
:loading="exportLoading"
/>
<Button
label="新增使用者"
icon="pi pi-plus"
@click="showCreateDialog = true"
/>
</div>
</div>
</div>
<!-- 統計卡片 -->
<div class="stats-cards">
<Card class="stats-card">
<template #content>
<div class="stats-content">
<div class="stats-icon total">
<i class="pi pi-users"></i>
</div>
<div class="stats-info">
<h3>{{ userStore.total }}</h3>
<p>總使用者</p>
</div>
</div>
</template>
</Card>
<Card class="stats-card">
<template #content>
<div class="stats-content">
<div class="stats-icon active">
<i class="pi pi-check-circle"></i>
</div>
<div class="stats-info">
<h3>{{ activeUsersCount }}</h3>
<p>活躍使用者</p>
</div>
</div>
</template>
</Card>
<Card class="stats-card">
<template #content>
<div class="stats-content">
<div class="stats-icon pending">
<i class="pi pi-clock"></i>
</div>
<div class="stats-info">
<h3>{{ pendingUsersCount }}</h3>
<p>待審核</p>
</div>
</div>
</template>
</Card>
<Card class="stats-card">
<template #content>
<div class="stats-content">
<div class="stats-icon new">
<i class="pi pi-user-plus"></i>
</div>
<div class="stats-info">
<h3>{{ newUsersThisMonth }}</h3>
<p>本月新增</p>
</div>
</div>
</template>
</Card>
</div>
<!-- 篩選和搜尋 -->
<Card class="filters-card">
<template #content>
<UserFilters
v-model:filters="userStore.filters"
@apply-filters="handleFiltersChange"
@clear-filters="userStore.clearFilters"
/>
</template>
</Card>
<!-- 使用者列表 -->
<Card class="user-list-card">
<template #header>
<div class="list-header">
<h3>使用者列表</h3>
<div class="list-actions" v-if="userStore.hasSelection">
<span class="selection-info">
已選擇 {{ userStore.selectionCount }} 個使用者
</span>
<Button
label="批次刪除"
icon="pi pi-trash"
@click="confirmBatchDelete"
class="p-button-danger p-button-outlined"
size="small"
/>
<Button
label="批次啟用"
icon="pi pi-check"
@click="batchUpdateStatus('active')"
class="p-button-success p-button-outlined"
size="small"
/>
<Button
label="取消選擇"
icon="pi pi-times"
@click="userStore.clearSelection"
class="p-button-text"
size="small"
/>
</div>
</div>
</template>
<template #content>
<UserList
:users="userStore.users"
:loading="userStore.loading"
:selected-users="userStore.selectedUsers"
@select-user="userStore.toggleUserSelection"
@select-all="userStore.selectAllUsers"
@edit-user="handleEditUser"
@delete-user="handleDeleteUser"
@view-user="handleViewUser"
/>
<!-- 分頁 -->
<Paginator
v-if="userStore.total > 0"
:rows="userStore.pageSize"
:totalRecords="userStore.total"
:first="(userStore.currentPage - 1) * userStore.pageSize"
@page="handlePageChange"
template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
:rowsPerPageOptions="[10, 20, 50]"
currentPageReportTemplate="顯示第 {first} 到 {last} 筆,共 {totalRecords} 筆記錄"
class="mt-4"
/>
</template>
</Card>
<!-- 新增/編輯使用者對話框 -->
<Dialog
v-model:visible="showCreateDialog"
modal
:header="editingUser ? '編輯使用者' : '新增使用者'"
:style="{ width: '600px' }"
class="user-dialog"
>
<UserForm
:user="editingUser"
:loading="userStore.loading"
@save="handleSaveUser"
@cancel="handleCancelForm"
/>
</Dialog>
<!-- 使用者詳情對話框 -->
<Dialog
v-model:visible="showDetailDialog"
modal
header="使用者詳情"
:style="{ width: '700px' }"
>
<UserDetail
:user="viewingUser"
@edit="handleEditFromDetail"
@close="showDetailDialog = false"
/>
</Dialog>
<!-- 確認刪除對話框 -->
<ConfirmDialog />
<!-- Toast 通知 -->
<Toast />
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/userStore'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import UserList from './components/UserList.vue'
import UserForm from './components/UserForm.vue'
import UserDetail from './components/UserDetail.vue'
import UserFilters from './components/UserFilters.vue'
export default {
name: 'UserManagement',
components: {
UserList,
UserForm,
UserDetail,
UserFilters
},
setup() {
const userStore = useUserStore()
const toast = useToast()
const confirm = useConfirm()
// 對話框狀態
const showCreateDialog = ref(false)
const showDetailDialog = ref(false)
const editingUser = ref(null)
const viewingUser = ref(null)
const exportLoading = ref(false)
// 統計數據
const activeUsersCount = computed(() =>
userStore.users.filter(user => user.status === 'active').length
)
const pendingUsersCount = computed(() =>
userStore.users.filter(user => user.status === 'pending').length
)
const newUsersThisMonth = computed(() => {
const thisMonth = new Date().getMonth()
const thisYear = new Date().getFullYear()
return userStore.users.filter(user => {
const joinDate = new Date(user.joinDate)
return joinDate.getMonth() === thisMonth && joinDate.getFullYear() === thisYear
}).length
})
// 事件處理
const handleEditUser = (user) => {
editingUser.value = { ...user }
showCreateDialog.value = true
}
const handleViewUser = (user) => {
viewingUser.value = user
showDetailDialog.value = true
}
const handleDeleteUser = (user) => {
confirm.require({
message: `確定要刪除使用者 "${user.username}" 嗎?此操作無法復原。`,
header: '確認刪除',
icon: 'pi pi-exclamation-triangle',
accept: async () => {
try {
await userStore.deleteUser(user.id)
toast.add({
severity: 'success',
summary: '刪除成功',
detail: `使用者 "${user.username}" 已成功刪除`,
life: 3000
})
} catch (error) {
toast.add({
severity: 'error',
summary: '刪除失敗',
detail: error.message,
life: 5000
})
}
}
})
}
const confirmBatchDelete = () => {
const selectedCount = userStore.selectionCount
confirm.require({
message: `確定要刪除選擇的 ${selectedCount} 個使用者嗎?此操作無法復原。`,
header: '批次刪除確認',
icon: 'pi pi-exclamation-triangle',
accept: async () => {
try {
await userStore.batchDeleteUsers([...userStore.selectedUsers])
toast.add({
severity: 'success',
summary: '批次刪除成功',
detail: `已成功刪除 ${selectedCount} 個使用者`,
life: 3000
})
} catch (error) {
toast.add({
severity: 'error',
summary: '批次刪除失敗',
detail: error.message,
life: 5000
})
}
}
})
}
const batchUpdateStatus = async (status) => {
// 批次更新狀態的實作
try {
// 這裡應該呼叫 API 批次更新狀態
// await userService.batchUpdateStatus(userStore.selectedUsers, status)
toast.add({
severity: 'success',
summary: '批次更新成功',
detail: `已更新 ${userStore.selectionCount} 個使用者的狀態`,
life: 3000
})
userStore.clearSelection()
await userStore.fetchUsers()
} catch (error) {
toast.add({
severity: 'error',
summary: '批次更新失敗',
detail: error.message,
life: 5000
})
}
}
const handleSaveUser = async (userData) => {
try {
if (editingUser.value?.id) {
await userStore.updateUser(editingUser.value.id, userData)
toast.add({
severity: 'success',
summary: '更新成功',
detail: '使用者資料已成功更新',
life: 3000
})
} else {
await userStore.createUser(userData)
toast.add({
severity: 'success',
summary: '新增成功',
detail: '新使用者已成功建立',
life: 3000
})
}
handleCancelForm()
} catch (error) {
toast.add({
severity: 'error',
summary: editingUser.value?.id ? '更新失敗' : '新增失敗',
detail: error.message,
life: 5000
})
}
}
const handleCancelForm = () => {
showCreateDialog.value = false
editingUser.value = null
}
const handleEditFromDetail = () => {
editingUser.value = { ...viewingUser.value }
showDetailDialog.value = false
showCreateDialog.value = true
}
const handleFiltersChange = () => {
userStore.fetchUsers(1)
}
const handlePageChange = (event) => {
const page = Math.floor(event.first / event.rows) + 1
userStore.pageSize = event.rows
userStore.fetchUsers(page)
}
const exportUsers = async () => {
exportLoading.value = true
try {
// 匯出功能實作
const blob = await userService.exportUsers(userStore.filters)
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `users_${new Date().toISOString().split('T')[0]}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.add({
severity: 'success',
summary: '匯出成功',
detail: '使用者資料已成功匯出',
life: 3000
})
} catch (error) {
toast.add({
severity: 'error',
summary: '匯出失敗',
detail: error.message,
life: 5000
})
} finally {
exportLoading.value = false
}
}
// 生命週期
onMounted(() => {
userStore.fetchUsers()
})
return {
userStore,
showCreateDialog,
showDetailDialog,
editingUser,
viewingUser,
exportLoading,
activeUsersCount,
pendingUsersCount,
newUsersThisMonth,
handleEditUser,
handleViewUser,
handleDeleteUser,
confirmBatchDelete,
batchUpdateStatus,
handleSaveUser,
handleCancelForm,
handleEditFromDetail,
handleFiltersChange,
handlePageChange,
exportUsers
}
}
}
</script>
<style scoped>
.user-management {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
margin-bottom: 2rem;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
}
.title-section {
flex: 1;
}
.page-title {
font-size: 2rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-title i {
color: #3498db;
}
.page-subtitle {
color: #7f8c8d;
font-size: 1.1rem;
margin: 0;
}
.header-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stats-card {
border: 1px solid #e9ecef;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stats-content {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem;
}
.stats-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stats-icon.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stats-icon.active {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.stats-icon.pending {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.stats-icon.new {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.stats-info h3 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.25rem;
color: #2c3e50;
}
.stats-info p {
margin: 0;
color: #7f8c8d;
font-weight: 500;
}
.filters-card,
.user-list-card {
margin-bottom: 1.5rem;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
}
.list-header h3 {
margin: 0;
color: #2c3e50;
}
.list-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.selection-info {
color: #3498db;
font-weight: 500;
font-size: 0.9rem;
}
.user-dialog :deep(.p-dialog-header) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.user-dialog :deep(.p-dialog-header .p-dialog-title) {
color: white;
}
.user-dialog :deep(.p-dialog-header .p-dialog-header-icon) {
color: white;
}
@media (max-width: 768px) {
.user-management {
padding: 1rem;
}
.header-content {
flex-direction: column;
align-items: stretch;
}
.header-actions {
justify-content: flex-start;
}
.stats-cards {
grid-template-columns: 1fr;
}
.list-header {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.list-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
}
@media (max-width: 480px) {
.page-title {
font-size: 1.5rem;
}
.header-actions {
flex-direction: column;
}
.stats-content {
flex-direction: column;
text-align: center;
}
}
</style>
UserList.vue - 使用者列表元件
<template>
<div class="user-list">
<DataTable
:value="users"
:loading="loading"
selectionMode="multiple"
:selection="selectedUsers"
@selection-change="handleSelectionChange"
dataKey="id"
responsiveLayout="scroll"
:paginator="false"
class="p-datatable-sm"
:rowHover="true"
stripedRows
>
<!-- 選擇欄 -->
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
<!-- 頭像和基本資訊 -->
<Column field="user" header="使用者" :sortable="false" style="min-width: 250px">
<template #body="{ data }">
<div class="user-info">
<img
:src="data.avatar || `https://ui-avatars.com/api/?name=${data.firstName}+${data.lastName}&background=random`"
:alt="data.username"
class="user-avatar"
@error="handleImageError"
/>
<div class="user-details">
<div class="user-name">{{ data.firstName }} {{ data.lastName }}</div>
<div class="user-username">@{{ data.username }}</div>
<div class="user-email">{{ data.email }}</div>
</div>
</div>
</template>
</Column>
<!-- 角色 -->
<Column field="role" header="角色" :sortable="true" style="width: 120px">
<template #body="{ data }">
<Tag
:value="getRoleLabel(data.role)"
:severity="getRoleSeverity(data.role)"
/>
</template>
</Column>
<!-- 部門 -->
<Column field="department" header="部門" :sortable="true" style="width: 120px">
<template #body="{ data }">
<div class="department-info">
<i :class="getDepartmentIcon(data.department)"></i>
<span>{{ getDepartmentLabel(data.department) }}</span>
</div>
</template>
</Column>
<!-- 狀態 -->
<Column field="status" header="狀態" :sortable="true" style="width: 120px">
<template #body="{ data }">
<Tag
:value="getStatusLabel(data.status)"
:severity="getStatusSeverity(data.status)"
:icon="getStatusIcon(data.status)"
/>
</template>
</Column>
<!-- 加入日期 -->
<Column field="joinDate" header="加入日期" :sortable="true" style="width: 150px">
<template #body="{ data }">
<div class="date-info">
<div class="date">{{ formatDate(data.joinDate) }}</div>
<div class="relative-time">{{ getRelativeTime(data.joinDate) }}</div>
</div>
</template>
</Column>
<!-- 最後登入 -->
<Column field="lastLogin" header="最後登入" :sortable="true" style="width: 150px">
<template #body="{ data }">
<div class="date-info" v-if="data.lastLogin">
<div class="date">{{ formatDate(data.lastLogin) }}</div>
<div class="relative-time">{{ getRelativeTime(data.lastLogin) }}</div>
</div>
<span v-else class="no-login">從未登入</span>
</template>
</Column>
<!-- 操作 -->
<Column header="操作" style="width: 200px">
<template #body="{ data }">
<div class="action-buttons">
<Button
icon="pi pi-eye"
@click="$emit('view-user', data)"
class="p-button-rounded p-button-text p-button-info"
size="small"
v-tooltip.top="'檢視詳情'"
/>
<Button
icon="pi pi-pencil"
@click="$emit('edit-user', data)"
class="p-button-rounded p-button-text p-button-warning"
size="small"
v-tooltip.top="'編輯'"
/>
<Button
icon="pi pi-trash"
@click="$emit('delete-user', data)"
class="p-button-rounded p-button-text p-button-danger"
size="small"
v-tooltip.top="'刪除'"
/>
<Button
:icon="data.status === 'active' ? 'pi pi-ban' : 'pi pi-check'"
@click="toggleUserStatus(data)"
:class="[
'p-button-rounded p-button-text',
data.status === 'active' ? 'p-button-secondary' : 'p-button-success'
]"
size="small"
:v-tooltip.top="data.status === 'active' ? '停用' : '啟用'"
/>
</div>
</template>
</Column>
<!-- 空狀態 -->
<template #empty>
<div class="empty-state">
<i class="pi pi-users" style="font-size: 3rem; color: #cbd5e0;"></i>
<h3>沒有找到使用者</h3>
<p>請調整搜尋條件或新增使用者</p>
</div>
</template>
<!-- 載入狀態 -->
<template #loading>
<div class="loading-overlay">
<ProgressSpinner style="width: 50px; height: 50px" />
<p>載入中...</p>
</div>
</template>
</DataTable>
</div>
</template>
<script>
import { format, formatDistanceToNow } from 'date-fns'
import { zhTW } from 'date-fns/locale'
export default {
name: 'UserList',
props: {
users: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
selectedUsers: {
type: Array,
default: () => []
}
},
emits: ['select-user', 'select-all', 'edit-user', 'delete-user', 'view-user', 'toggle-status'],
setup(props, { emit }) {
const handleSelectionChange = (event) => {
// 處理選擇變更
const newSelection = event.value
const oldSelection = props.selectedUsers
// 找出新增或移除的項目
const added = newSelection.filter(id => !oldSelection.includes(id))
const removed = oldSelection.filter(id => !newSelection.includes(id))
if (added.length > 0) {
added.forEach(userId => emit('select-user', userId))
}
if (removed.length > 0) {
removed.forEach(userId => emit('select-user', userId))
}
}
const toggleUserStatus = (user) => {
emit('toggle-status', user)
}
const handleImageError = (event) => {
event.target.src = 'https://via.placeholder.com/40x40?text=?'
}
// 格式化函數
const formatDate = (date) => {
if (!date) return '-'
return format(new Date(date), 'yyyy/MM/dd', { locale: zhTW })
}
const getRelativeTime = (date) => {
if (!date) return '-'
return formatDistanceToNow(new Date(date), {
addSuffix: true,
locale: zhTW
})
}
// 標籤和樣式函數
const getRoleLabel = (role) => {
const roleLabels = {
admin: '管理員',
manager: '經理',
user: '使用者',
guest: '訪客'
}
return roleLabels[role] || role
}
const getRoleSeverity = (role) => {
const severities = {
admin: 'danger',
manager: 'warning',
user: 'info',
guest: 'secondary'
}
return severities[role] || 'info'
}
const getDepartmentLabel = (department) => {
const departmentLabels = {
engineering: '工程部',
design: '設計部',
marketing: '行銷部',
sales: '業務部',
hr: '人資部'
}
return departmentLabels[department] || department
}
const getDepartmentIcon = (department) => {
const departmentIcons = {
engineering: 'pi pi-cog',
design: 'pi pi-palette',
marketing: 'pi pi-megaphone',
sales: 'pi pi-chart-line',
hr: 'pi pi-users'
}
return departmentIcons[department] || 'pi pi-building'
}
const getStatusLabel = (status) => {
const statusLabels = {
active: '啟用',
inactive: '停用',
pending: '待審核',
suspended: '暫停'
}
return statusLabels[status] || status
}
const getStatusSeverity = (status) => {
const severities = {
active: 'success',
inactive: 'secondary',
pending: 'warning',
suspended: 'danger'
}
return severities[status] || 'info'
}
const getStatusIcon = (status) => {
const statusIcons = {
active: 'pi pi-check-circle',
inactive: 'pi pi-ban',
pending: 'pi pi-clock',
suspended: 'pi pi-exclamation-triangle'
}
return statusIcons[status] || 'pi pi-question-circle'
}
return {
handleSelectionChange,
toggleUserStatus,
handleImageError,
formatDate,
getRelativeTime,
getRoleLabel,
getRoleSeverity,
getDepartmentLabel,
getDepartmentIcon,
getStatusLabel,
getStatusSeverity,
getStatusIcon
}
}
}
</script>
<style scoped>
.user-list {
width: 100%;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e9ecef;
}
.user-details {
flex: 1;
min-width: 0;
}
.user-name {
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.125rem;
}
.user-username {
font-size: 0.875rem;
color: #3498db;
margin-bottom: 0.125rem;
}
.user-email {
font-size: 0.8rem;
color: #7f8c8d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.department-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.department-info i {
color: #3498db;
}
.date-info {
display: flex;
flex-direction: column;
}
.date-info .date {
font-weight: 500;
color: #2c3e50;
margin-bottom: 0.125rem;
}
.date-info .relative-time {
font-size: 0.8rem;
color: #7f8c8d;
}
.no-login {
color: #bdc3c7;
font-style: italic;
font-size: 0.875rem;
}
.action-buttons {
display: flex;
gap: 0.25rem;
justify-content: center;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.empty-state h3 {
margin: 1rem 0 0.5rem 0;
color: #2c3e50;
}
.empty-state p {
margin: 0;
color: #7f8c8d;
}
.loading-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
}
.loading-overlay p {
color: #7f8c8d;
margin: 0;
}
/* 響應式調整 */
@media (max-width: 768px) {
.user-info {
flex-direction: column;
align-items: flex-start;
text-align: left;
}
.action-buttons {
flex-direction: column;
}
.date-info {
font-size: 0.875rem;
}
}
/* DataTable 客製化 */
:deep(.p-datatable .p-datatable-thead > tr > th) {
background: #f8f9fa;
border-color: #e9ecef;
font-weight: 600;
color: #495057;
}
:deep(.p-datatable .p-datatable-tbody > tr) {
transition: background-color 0.2s ease;
}
:deep(.p-datatable .p-datatable-tbody > tr:hover) {
background: #f8f9fa;
}
:deep(.p-datatable .p-datatable-tbody > tr.p-highlight) {
background: #e3f2fd;
color: #1976d2;
}
:deep(.p-datatable .p-datatable-tbody > tr.p-highlight:hover) {
background: #bbdefb;
}
</style>
UserForm.vue - 使用者表單元件
<template>
<div class="user-form">
<form @submit.prevent="handleSubmit" class="form-container">
<!-- 基本資訊區塊 -->
<div class="form-section">
<h4 class="section-title">
<i class="pi pi-user"></i>
基本資訊
</h4>
<div class="form-grid">
<div class="field">
<label for="firstName" class="required">名字</label>
<InputText
id="firstName"
v-model="formData.firstName"
:class="{ 'p-invalid': errors.firstName }"
placeholder="請輸入名字"
/>
<small v-if="errors.firstName" class="p-error">{{ errors.firstName }}</small>
</div>
<div class="field">
<label for="lastName" class="required">姓氏</label>
<InputText
id="lastName"
v-model="formData.lastName"
:class="{ 'p-invalid': errors.lastName }"
placeholder="請輸入姓氏"
/>
<small v-if="errors.lastName" class="p-error">{{ errors.lastName }}</small>
</div>
<div class="field">
<label for="username" class="required">使用者名稱</label>
<InputText
id="username"
v-model="formData.username"
:class="{ 'p-invalid': errors.username }"
placeholder="請輸入使用者名稱"
@blur="checkUsernameAvailability"
/>
<small v-if="errors.username" class="p-error">{{ errors.username }}</small>
<small v-else-if="usernameChecking" class="p-info">檢查使用者名稱可用性...</small>
<small v-else-if="usernameAvailable === true" class="p-success">使用者名稱可用</small>
<small v-else-if="usernameAvailable === false" class="p-error">使用者名稱已被使用</small>
</div>
<div class="field">
<label for="email" class="required">Email</label>
<InputText
id="email"
v-model="formData.email"
type="email"
:class="{ 'p-invalid': errors.email }"
placeholder="請輸入 Email 地址"
/>
<small v-if="errors.email" class="p-error">{{ errors.email }}</small>
</div>
<div class="field">
<label for="phone">電話號碼</label>
<InputText
id="phone"
v-model="formData.phone"
:class="{ 'p-invalid': errors.phone }"
placeholder="請輸入電話號碼"
/>
<small v-if="errors.phone" class="p-error">{{ errors.phone }}</small>
</div>
</div>
</div>
<!-- 職位資訊區塊 -->
<div class="form-section">
<h4 class="section-title">
<i class="pi pi-briefcase"></i>
職位資訊
</h4>
<div class="form-grid">
<div class="field">
<label for="role" class="required">角色</label>
<Dropdown
id="role"
v-model="formData.role"
:options="roleOptions"
optionLabel="label"
optionValue="value"
placeholder="請選擇角色"
:class="{ 'p-invalid': errors.role }"
/>
<small v-if="errors.role" class="p-error">{{ errors.role }}</small>
</div>
<div class="field">
<label for="department" class="required">部門</label>
<Dropdown
id="department"
v-model="formData.department"
:options="departmentOptions"
optionLabel="label"
optionValue="value"
placeholder="請選擇部門"
:class="{ 'p-invalid': errors.department }"
/>
<small v-if="errors.department" class="p-error">{{ errors.department }}</small>
</div>
<div class="field">
<label for="status">狀態</label>
<Dropdown
id="status"
v-model="formData.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="請選擇狀態"
/>
</div>
<div class="field">
<label for="joinDate">加入日期</label>
<Calendar
id="joinDate"
v-model="formData.joinDate"
dateFormat="yy/mm/dd"
placeholder="請選擇加入日期"
:maxDate="new Date()"
showIcon
/>
</div>
</div>
</div>
<!-- 權限設定區塊 -->
<div class="form-section">
<h4 class="section-title">
<i class="pi pi-shield"></i>
權限設定
</h4>
<div class="permissions-grid">
<div class="permission-group" v-for="group in permissionGroups" :key="group.name">
<h5 class="permission-group-title">{{ group.label }}</h5>
<div class="permission-items">
<div
v-for="permission in group.permissions"
:key="permission.value"
class="permission-item"
>
<Checkbox
:id="permission.value"
v-model="formData.permissions"
:value="permission.value"
/>
<label :for="permission.value" class="permission-label">
{{ permission.label }}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 頭像上傳區塊 -->
<div class="form-section">
<h4 class="section-title">
<i class="pi pi-image"></i>
頭像設定
</h4>
<div class="avatar-section">
<div class="avatar-preview">
<img
:src="avatarPreview || defaultAvatar"
alt="頭像預覽"
class="avatar-image"
/>
</div>
<div class="avatar-upload">
<FileUpload
mode="basic"
accept="image/*"
:maxFileSize="1000000"
chooseLabel="選擇頭像"
@select="handleAvatarUpload"
@clear="handleAvatarClear"
class="p-button-outlined"
/>
<small class="upload-hint">支援 JPG、PNG 格式,檔案大小不超過 1MB</small>
</div>
</div>
</div>
<!-- 密碼設定區塊 (僅新增時顯示) -->
<div class="form-section" v-if="!isEdit">
<h4 class="section-title">
<i class="pi pi-lock"></i>
密碼設定
</h4>
<div class="form-grid">
<div class="field">
<label for="password" class="required">密碼</label>
<Password
id="password"
v-model="formData.password"
:class="{ 'p-invalid': errors.password }"
placeholder="請輸入密碼"
toggleMask
:feedback="true"
promptLabel="請輸入密碼"
weakLabel="弱"
mediumLabel="中等"
strongLabel="強"
/>
<small v-if="errors.password" class="p-error">{{ errors.password }}</small>
</div>
<div class="field">
<label for="confirmPassword" class="required">確認密碼</label>
<Password
id="confirmPassword"
v-model="formData.confirmPassword"
:class="{ 'p-invalid': errors.confirmPassword }"
placeholder="請再次輸入密碼"
:feedback="false"
/>
<small v-if="errors.confirmPassword" class="p-error">{{ errors.confirmPassword }}</small>
</div>
</div>
</div>
<!-- 其他設定 -->
<div class="form-section">
<h4 class="section-title">
<i class="pi pi-cog"></i>
其他設定
</h4>
<div class="checkbox-group">
<div class="checkbox-item">
<Checkbox
id="isEmailVerified"
v-model="formData.isEmailVerified"
:binary="true"
/>
<label for="isEmailVerified">Email 已驗證</label>
</div>
<div class="checkbox-item">
<Checkbox
id="sendWelcomeEmail"
v-model="formData.sendWelcomeEmail"
:binary="true"
/>
<label for="sendWelcomeEmail">發送歡迎郵件</label>
</div>
<div class="checkbox-item" v-if="!isEdit">
<Checkbox
id="requirePasswordChange"
v-model="formData.requirePasswordChange"
:binary="true"
/>
<label for="requirePasswordChange">首次登入需要變更密碼</label>
</div>
</div>
</div>
<!-- 表單按鈕 -->
<div class="form-actions">
<Button
type="button"
label="取消"
icon="pi pi-times"
@click="$emit('cancel')"
class="p-button-text"
/>
<Button
type="submit"
:label="isEdit ? '更新' : '建立'"
:icon="isEdit ? 'pi pi-check' : 'pi pi-plus'"
:loading="loading"
class="p-button-primary"
/>
</div>
</form>
</div>
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { UserRole, Department, UserStatus } from '@/models/User'
export default {
name: 'UserForm',
props: {
user: {
type: Object,
default: null
},
loading: {
type: Boolean,
default: false
}
},
emits: ['save', 'cancel'],
setup(props, { emit }) {
const toast = useToast()
// 表單狀態
const isEdit = computed(() => !!props.user?.id)
const errors = ref({})
const usernameChecking = ref(false)
const usernameAvailable = ref(null)
const avatarPreview = ref('')
// 表單資料
const formData = ref({
firstName: '',
lastName: '',
username: '',
email: '',
phone: '',
role: UserRole.USER,
department: Department.ENGINEERING,
status: UserStatus.ACTIVE,
joinDate: new Date(),
permissions: [],
avatar: '',
password: '',
confirmPassword: '',
isEmailVerified: false,
sendWelcomeEmail: true,
requirePasswordChange: true
})
// 選項資料
const roleOptions = ref([
{ label: '管理員', value: UserRole.ADMIN },
{ label: '經理', value: UserRole.MANAGER },
{ label: '使用者', value: UserRole.USER },
{ label: '訪客', value: UserRole.GUEST }
])
const departmentOptions = ref([
{ label: '工程部', value: Department.ENGINEERING },
{ label: '設計部', value: Department.DESIGN },
{ label: '行銷部', value: Department.MARKETING },
{ label: '業務部', value: Department.SALES },
{ label: '人資部', value: Department.HR }
])
const statusOptions = ref([
{ label: '啟用', value: UserStatus.ACTIVE },
{ label: '停用', value: UserStatus.INACTIVE },
{ label: '待審核', value: UserStatus.PENDING },
{ label: '暫停', value: UserStatus.SUSPENDED }
])
const permissionGroups = ref([
{
name: 'user',
label: '使用者管理',
permissions: [
{ label: '檢視使用者', value: 'user.view' },
{ label: '建立使用者', value: 'user.create' },
{ label: '編輯使用者', value: 'user.edit' },
{ label: '刪除使用者', value: 'user.delete' }
]
},
{
name: 'role',
label: '角色管理',
permissions: [
{ label: '檢視角色', value: 'role.view' },
{ label: '管理角色', value: 'role.manage' }
]
},
{
name: 'system',
label: '系統管理',
permissions: [
{ label: '系統設定', value: 'system.config' },
{ label: '檢視日誌', value: 'system.logs' },
{ label: '系統監控', value: 'system.monitor' }
]
}
])
const defaultAvatar = computed(() => {
if (formData.value.firstName && formData.value.lastName) {
return `https://ui-avatars.com/api/?name=${formData.value.firstName}+${formData.value.lastName}&background=random`
}
return 'https://via.placeholder.com/120x120?text=?'
})
// 表單驗證
const validateForm = () => {
errors.value = {}
if (!formData.value.firstName?.trim()) {
errors.value.firstName = '名字為必填欄位'
}
if (!formData.value.lastName?.trim()) {
errors.value.lastName = '姓氏為必填欄位'
}
if (!formData.value.username?.trim()) {
errors.value.username = '使用者名稱為必填欄位'
} else if (formData.value.username.length < 3) {
errors.value.username = '使用者名稱至少需要 3 個字元'
}
if (!formData.value.email?.trim()) {
errors.value.email = 'Email 為必填欄位'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.value.email)) {
errors.value.email = '請輸入有效的 Email 地址'
}
if (!formData.value.role) {
errors.value.role = '角色為必填欄位'
}
if (!formData.value.department) {
errors.value.department = '部門為必填欄位'
}
if (formData.value.phone && !/^[\d-+().\s]+$/.test(formData.value.phone)) {
errors.value.phone = '請輸入有效的電話號碼'
}
// 密碼驗證(僅新增時)
if (!isEdit.value) {
if (!formData.value.password) {
errors.value.password = '密碼為必填欄位'
} else if (formData.value.password.length < 8) {
errors.value.password = '密碼至少需要 8 個字元'
}
if (!formData.value.confirmPassword) {
errors.value.confirmPassword = '請確認密碼'
} else if (formData.value.password !== formData.value.confirmPassword) {
errors.value.confirmPassword = '密碼確認不符'
}
}
return Object.keys(errors.value).length === 0
}
// 檢查使用者名稱可用性
const checkUsernameAvailability = async () => {
if (!formData.value.username || formData.value.username.length < 3) {
usernameAvailable.value = null
return
}
// 如果是編輯模式且使用者名稱沒有改變,則不檢查
if (isEdit.value && formData.value.username === props.user?.username) {
usernameAvailable.value = true
return
}
usernameChecking.value = true
usernameAvailable.value = null
try {
// 模擬 API 呼叫
await new Promise(resolve => setTimeout(resolve, 1000))
// 模擬檢查結果
const unavailableUsernames = ['admin', 'root', 'test', 'user', 'demo']
usernameAvailable.value = !unavailableUsernames.includes(formData.value.username.toLowerCase())
} catch (error) {
console.error('檢查使用者名稱時發生錯誤:', error)
} finally {
usernameChecking.value = false
}
}
// 頭像處理
const handleAvatarUpload = (event) => {
const file = event.files[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
avatarPreview.value = e.target.result
formData.value.avatar = e.target.result
}
reader.readAsDataURL(file)
}
}
const handleAvatarClear = () => {
avatarPreview.value = ''
formData.value.avatar = ''
}
// 表單提交
const handleSubmit = () => {
if (!validateForm()) {
toast.add({
severity: 'warn',
summary: '表單驗證失敗',
detail: '請檢查並修正表單中的錯誤',
life: 3000
})
return
}
if (!isEdit.value && usernameAvailable.value === false) {
toast.add({
severity: 'error',
summary: '使用者名稱不可用',
detail: '請選擇其他使用者名稱',
life: 3000
})
return
}
// 準備提交的資料
const submitData = { ...formData.value }
delete submitData.confirmPassword
if (isEdit.value) {
delete submitData.password
delete submitData.sendWelcomeEmail
delete submitData.requirePasswordChange
}
emit('save', submitData)
}
// 初始化表單資料
const initFormData = () => {
if (props.user) {
Object.keys(formData.value).forEach(key => {
if (props.user[key] !== undefined) {
formData.value[key] = props.user[key]
}
})
if (props.user.avatar) {
avatarPreview.value = props.user.avatar
}
} else {
// 重設為預設值
formData.value = {
firstName: '',
lastName: '',
username: '',
email: '',
phone: '',
role: UserRole.USER,
department: Department.ENGINEERING,
status: UserStatus.ACTIVE,
joinDate: new Date(),
permissions: [],
avatar: '',
password: '',
confirmPassword: '',
isEmailVerified: false,
sendWelcomeEmail: true,
requirePasswordChange: true
}
avatarPreview.value = ''
usernameAvailable.value = null
}
}
// 監聽 user prop 變化
watch(() => props.user, initFormData, { immediate: true })
onMounted(() => {
initFormData()
})
return {
isEdit,
errors,
usernameChecking,
usernameAvailable,
avatarPreview,
formData,
roleOptions,
departmentOptions,
statusOptions,
permissionGroups,
defaultAvatar,
validateForm,
checkUsernameAvailability,
handleAvatarUpload,
handleAvatarClear,
handleSubmit
}
}
}
</script>
<style scoped>
.user-form {
max-width: 100%;
margin: 0 auto;
}
.form-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.form-section {
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
}
.section-title {
background: #f8f9fa;
padding: 1rem 1.5rem;
margin: 0;
font-size: 1.1rem;
color: #495057;
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-title i {
color: #3498db;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
padding: 1.5rem;
}
.field {
display: flex;
flex-direction: column;
}
.field label {
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.field label.required::after {
content: ' *';
color: #ef4444;
}
.p-error {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.p-info {
color: #3b82f6;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.p-success {
color: #10b981;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
padding: 1.5rem;
}
.permission-group {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 1rem;
}
.permission-group-title {
margin: 0 0 1rem 0;
font-size: 0.95rem;
font-weight: 600;
color: #374151;
}
.permission-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.permission-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.permission-label {
font-size: 0.9rem;
color: #4b5563;
cursor: pointer;
}
.avatar-section {
display: flex;
align-items: center;
gap: 2rem;
padding: 1.5rem;
}
.avatar-preview {
flex-shrink: 0;
}
.avatar-image {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #e5e7eb;
}
.avatar-upload {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upload-hint {
color: #6b7280;
font-size: 0.8rem;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox-item label {
color: #374151;
cursor: pointer;
margin: 0;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem 0 0 0;
border-top: 1px solid #e9ecef;
margin-top: 1rem;
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem;
}
.permissions-grid {
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem;
}
.avatar-section {
flex-direction: column;
text-align: center;
padding: 1rem;
}
.form-actions {
flex-direction: column-reverse;
}
}
/* PrimeVue 元件客製化 */
:deep(.p-password) {
width: 100%;
}
:deep(.p-dropdown) {
width: 100%;
}
:deep(.p-calendar) {
width: 100%;
}
:deep(.p-fileupload-choose) {
width: auto;
}
</style>
UserFilters.vue - 篩選元件
<template>
<div class="user-filters">
<div class="filters-header">
<h4 class="filters-title">
<i class="pi pi-filter"></i>
篩選條件
</h4>
<div class="filters-actions">
<Button
label="清除篩選"
icon="pi pi-filter-slash"
@click="clearAllFilters"
class="p-button-text p-button-sm"
/>
<Button
label="套用篩選"
icon="pi pi-check"
@click="applyFilters"
class="p-button-sm"
/>
</div>
</div>
<div class="filters-content">
<!-- 快速搜尋 -->
<div class="filter-group search-group">
<label for="search">快速搜尋</label>
<span class="p-input-icon-left search-input">
<i class="pi pi-search"></i>
<InputText
id="search"
v-model="localFilters.search"
placeholder="搜尋使用者名稱、Email..."
@keyup.enter="applyFilters"
/>
</span>
</div>
<!-- 篩選條件 -->
<div class="filters-grid">
<!-- 角色篩選 -->
<div class="filter-group">
<label for="roleFilter">角色</label>
<MultiSelect
id="roleFilter"
v-model="localFilters.roles"
:options="roleOptions"
optionLabel="label"
optionValue="value"
placeholder="選擇角色"
display="chip"
:showClear="true"
/>
</div>
<!-- 部門篩選 -->
<div class="filter-group">
<label for="departmentFilter">部門</label>
<MultiSelect
id="departmentFilter"
v-model="localFilters.departments"
:options="departmentOptions"
optionLabel="label"
optionValue="value"
placeholder="選擇部門"
display="chip"
:showClear="true"
/>
</div>
<!-- 狀態篩選 -->
<div class="filter-group">
<label for="statusFilter">狀態</label>
<MultiSelect
id="statusFilter"
v-model="localFilters.statuses"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="選擇狀態"
display="chip"
:showClear="true"
/>
</div>
<!-- 加入日期範圍 -->
<div class="filter-group">
<label for="dateRange">加入日期</label>
<Calendar
id="dateRange"
v-model="localFilters.dateRange"
selectionMode="range"
:manualInput="false"
dateFormat="yy/mm/dd"
placeholder="選擇日期範圍"
:showIcon="true"
/>
</div>
<!-- Email 驗證狀態 -->
<div class="filter-group">
<label for="emailVerified">Email 驗證</label>
<Dropdown
id="emailVerified"
v-model="localFilters.emailVerified"
:options="emailVerifiedOptions"
optionLabel="label"
optionValue="value"
placeholder="選擇驗證狀態"
:showClear="true"
/>
</div>
<!-- 最後登入時間 -->
<div class="filter-group">
<label for="lastLoginFilter">最後登入</label>
<Dropdown
id="lastLoginFilter"
v-model="localFilters.lastLoginPeriod"
:options="lastLoginOptions"
optionLabel="label"
optionValue="value"
placeholder="選擇時間範圍"
:showClear="true"
/>
</div>
</div>
<!-- 進階篩選開關 -->
<div class="advanced-toggle">
<Button
:label="showAdvanced ? '隱藏進階篩選' : '顯示進階篩選'"
:icon="showAdvanced ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
@click="showAdvanced = !showAdvanced"
class="p-button-text p-button-sm"
/>
</div>
<!-- 進階篩選 -->
<div v-show="showAdvanced" class="advanced-filters">
<div class="filters-grid">
<!-- 權限篩選 -->
<div class="filter-group">
<label for="permissionFilter">權限</label>
<MultiSelect
id="permissionFilter"
v-model="localFilters.permissions"
:options="permissionOptions"
optionLabel="label"
optionValue="value"
placeholder="選擇權限"
display="chip"
:showClear="true"
/>
</div>
<!-- 帳戶年齡 -->
<div class="filter-group">
<label for="accountAge">帳戶年齡</label>
<Dropdown
id="accountAge"
v-model="localFilters.accountAge"
:options="accountAgeOptions"
optionLabel="label"
optionValue="value"
placeholder="選擇帳戶年齡"
:showClear="true"
/>
</div>
<!-- 活躍度 -->
<div class="filter-group">
<label for="activityLevel">活躍度</label>
<Dropdown
id="activityLevel"
v-model="localFilters.activityLevel"
:options="activityLevelOptions"
optionLabel="label"
optionValue="value"
placeholder="選擇活躍度"
:showClear="true"
/>
</div>
<!-- 自訂標籤 -->
<div class="filter-group">
<label for="customTags">標籤</label>
<Chips
id="customTags"
v-model="localFilters.tags"
placeholder="輸入標籤名稱..."
:allowDuplicate="false"
/>
</div>
</div>
</div>
<!-- 篩選統計 -->
<div class="filter-stats" v-if="hasActiveFilters">
<div class="stats-info">
<i class="pi pi-info-circle"></i>
<span>已套用 {{ activeFilterCount }} 個篩選條件</span>
</div>
<div class="active-filters">
<Tag
v-for="(filter, index) in activeFilterLabels"
:key="index"
:value="filter"
severity="info"
class="filter-tag"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, watch } from 'vue'
import { UserRole, Department, UserStatus } from '@/models/User'
export default {
name: 'UserFilters',
props: {
filters: {
type: Object,
default: () => ({})
}
},
emits: ['apply-filters', 'clear-filters'],
setup(props, { emit }) {
const showAdvanced = ref(false)
// 本地篩選狀態
const localFilters = ref({
search: '',
roles: [],
departments: [],
statuses: [],
dateRange: null,
emailVerified: null,
lastLoginPeriod: null,
permissions: [],
accountAge: null,
activityLevel: null,
tags: []
})
// 選項資料
const roleOptions = ref([
{ label: '管理員', value: UserRole.ADMIN },
{ label: '經理', value: UserRole.MANAGER },
{ label: '使用者', value: UserRole.USER },
{ label: '訪客', value: UserRole.GUEST }
])
const departmentOptions = ref([
{ label: '工程部', value: Department.ENGINEERING },
{ label: '設計部', value: Department.DESIGN },
{ label: '行銷部', value: Department.MARKETING },
{ label: '業務部', value: Department.SALES },
{ label: '人資部', value: Department.HR }
])
const statusOptions = ref([
{ label: '啟用', value: UserStatus.ACTIVE },
{ label: '停用', value: UserStatus.INACTIVE },
{ label: '待審核', value: UserStatus.PENDING },
{ label: '暫停', value: UserStatus.SUSPENDED }
])
const emailVerifiedOptions = ref([
{ label: '已驗證', value: true },
{ label: '未驗證', value: false }
])
const lastLoginOptions = ref([
{ label: '今天', value: 'today' },
{ label: '本週', value: 'this_week' },
{ label: '本月', value: 'this_month' },
{ label: '三個月內', value: 'three_months' },
{ label: '六個月內', value: 'six_months' },
{ label: '一年內', value: 'one_year' },
{ label: '超過一年', value: 'over_year' },
{ label: '從未登入', value: 'never' }
])
const permissionOptions = ref([
{ label: '檢視使用者', value: 'user.view' },
{ label: '建立使用者', value: 'user.create' },
{ label: '編輯使用者', value: 'user.edit' },
{ label: '刪除使用者', value: 'user.delete' },
{ label: '檢視角色', value: 'role.view' },
{ label: '管理角色', value: 'role.manage' },
{ label: '系統設定', value: 'system.config' },
{ label: '檢視日誌', value: 'system.logs' },
{ label: '系統監控', value: 'system.monitor' }
])
const accountAgeOptions = ref([
{ label: '新用戶 (< 1個月)', value: 'new' },
{ label: '1-3個月', value: 'recent' },
{ label: '3-6個月', value: 'medium' },
{ label: '6-12個月', value: 'established' },
{ label: '超過一年', value: 'veteran' }
])
const activityLevelOptions = ref([
{ label: '非常活躍', value: 'very_active' },
{ label: '活躍', value: 'active' },
{ label: '一般', value: 'moderate' },
{ label: '不活躍', value: 'inactive' },
{ label: '長期未登入', value: 'dormant' }
])
// 計算屬性
const hasActiveFilters = computed(() => {
return localFilters.value.search ||
localFilters.value.roles.length > 0 ||
localFilters.value.departments.length > 0 ||
localFilters.value.statuses.length > 0 ||
localFilters.value.dateRange ||
localFilters.value.emailVerified !== null ||
localFilters.value.lastLoginPeriod ||
localFilters.value.permissions.length > 0 ||
localFilters.value.accountAge ||
localFilters.value.activityLevel ||
localFilters.value.tags.length > 0
})
const activeFilterCount = computed(() => {
let count = 0
if (localFilters.value.search) count++
if (localFilters.value.roles.length > 0) count++
if (localFilters.value.departments.length > 0) count++
if (localFilters.value.statuses.length > 0) count++
if (localFilters.value.dateRange) count++
if (localFilters.value.emailVerified !== null) count++
if (localFilters.value.lastLoginPeriod) count++
if (localFilters.value.permissions.length > 0) count++
if (localFilters.value.accountAge) count++
if (localFilters.value.activityLevel) count++
if (localFilters.value.tags.length > 0) count++
return count
})
const activeFilterLabels = computed(() => {
const labels = []
if (localFilters.value.search) {
labels.push(`搜尋: "${localFilters.value.search}"`)
}
if (localFilters.value.roles.length > 0) {
const roleLabels = localFilters.value.roles.map(role =>
roleOptions.value.find(r => r.value === role)?.label
).join(', ')
labels.push(`角色: ${roleLabels}`)
}
if (localFilters.value.departments.length > 0) {
const deptLabels = localFilters.value.departments.map(dept =>
departmentOptions.value.find(d => d.value === dept)?.label
).join(', ')
labels.push(`部門: ${deptLabels}`)
}
if (localFilters.value.statuses.length > 0) {
const statusLabels = localFilters.value.statuses.map(status =>
statusOptions.value.find(s => s.value === status)?.label
).join(', ')
labels.push(`狀態: ${statusLabels}`)
}
return labels
})
// 方法
const applyFilters = () => {
emit('apply-filters', { ...localFilters.value })
}
const clearAllFilters = () => {
localFilters.value = {
search: '',
roles: [],
departments: [],
statuses: [],
dateRange: null,
emailVerified: null,
lastLoginPeriod: null,
permissions: [],
accountAge: null,
activityLevel: null,
tags: []
}
emit('clear-filters')
}
// 監聽外部篩選變化
watch(() => props.filters, (newFilters) => {
Object.assign(localFilters.value, newFilters)
}, { deep: true, immediate: true })
return {
showAdvanced,
localFilters,
roleOptions,
departmentOptions,
statusOptions,
emailVerifiedOptions,
lastLoginOptions,
permissionOptions,
accountAgeOptions,
activityLevelOptions,
hasActiveFilters,
activeFilterCount,
activeFilterLabels,
applyFilters,
clearAllFilters
}
}
}
</script>
<style scoped>
.user-filters {
background: white;
border-radius: 8px;
overflow: hidden;
}
.filters-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.filters-title {
margin: 0;
font-size: 1.1rem;
color: #495057;
display: flex;
align-items: center;
gap: 0.5rem;
}
.filters-title i {
color: #3498db;
}
.filters-actions {
display: flex;
gap: 0.5rem;
}
.filters-content {
padding: 1.5rem;
}
.search-group {
margin-bottom: 1.5rem;
}
.search-input {
width: 100%;
}
.search-input .p-inputtext {
width: 100%;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.filter-group {
display: flex;
flex-direction: column;
}
.filter-group label {
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
font-size: 0.9rem;
}
.advanced-toggle {
text-align: center;
margin: 1.5rem 0;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
.advanced-filters {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e9ecef;
}
.filter-stats {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
.stats-info {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #6b7280;
}
.stats-info i {
color: #3b82f6;
}
.active-filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-tag {
font-size: 0.8rem;
}
/* PrimeVue 元件客製化 */
:deep(.p-multiselect) {
width: 100%;
}
:deep(.p-dropdown) {
width: 100%;
}
:deep(.p-calendar) {
width: 100%;
}
:deep(.p-chips) {
width: 100%;
}
:deep(.p-multiselect-token) {
background: #e3f2fd;
color: #1976d2;
}
@media (max-width: 768px) {
.filters-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.filters-actions {
justify-content: center;
}
.filters-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.filters-content {
padding: 1rem;
}
.active-filters {
justify-content: center;
}
}
</style>
本節要點回顧:
- ✅ 實作了完整的主頁面元件,包含統計卡片和批次操作
- ✅ 建立了功能豐富的使用者列表元件,支援排序、選擇、操作
- ✅ 設計了詳細的使用者表單,包含驗證、權限設定、頭像上傳
- ✅ 創建了進階篩選元件,支援多種篩選條件和搜尋
- ✅ 實現了使用者詳情檢視,包含活動記錄和安全設定
- ✅ 整合了響應式設計和無障礙支援
UserDetail.vue - 使用者詳情元件
<template>
<div class="user-detail">
<div class="detail-content">
<!-- 使用者基本資訊 -->
<div class="user-header">
<div class="user-avatar-section">
<img
:src="user.avatar || defaultAvatar"
:alt="user.username"
class="user-avatar-large"
/>
<div class="avatar-overlay">
<Tag
:value="getStatusLabel(user.status)"
:severity="getStatusSeverity(user.status)"
:icon="getStatusIcon(user.status)"
class="status-tag"
/>
</div>
</div>
<div class="user-basic-info">
<h2 class="user-full-name">{{ user.firstName }} {{ user.lastName }}</h2>
<p class="user-username">@{{ user.username }}</p>
<p class="user-email">
<i class="pi pi-envelope"></i>
{{ user.email }}
<Tag
v-if="user.isEmailVerified"
value="已驗證"
severity="success"
icon="pi pi-check"
class="verification-tag"
/>
<Tag
v-else
value="未驗證"
severity="warning"
icon="pi pi-exclamation-triangle"
class="verification-tag"
/>
</p>
<p class="user-phone" v-if="user.phone">
<i class="pi pi-phone"></i>
{{ user.phone }}
</p>
</div>
<div class="user-actions">
<Button
label="編輯使用者"
icon="pi pi-pencil"
@click="$emit('edit')"
class="p-button-primary"
/>
<Button
label="重設密碼"
icon="pi pi-key"
@click="handleResetPassword"
class="p-button-outlined"
/>
<Button
:label="user.status === 'active' ? '停用帳戶' : '啟用帳戶'"
:icon="user.status === 'active' ? 'pi pi-ban' : 'pi pi-check'"
@click="handleToggleStatus"
:class="user.status === 'active' ? 'p-button-warning' : 'p-button-success'"
outlined
/>
</div>
</div>
<!-- 詳細資訊標籤頁 -->
<TabView class="detail-tabs">
<!-- 基本資訊 -->
<TabPanel header="基本資訊" leftIcon="pi pi-user">
<div class="info-grid">
<div class="info-card">
<h4>職位資訊</h4>
<div class="info-items">
<div class="info-item">
<span class="label">角色:</span>
<Tag
:value="getRoleLabel(user.role)"
:severity="getRoleSeverity(user.role)"
/>
</div>
<div class="info-item">
<span class="label">部門:</span>
<span class="value">
<i :class="getDepartmentIcon(user.department)"></i>
{{ getDepartmentLabel(user.department) }}
</span>
</div>
<div class="info-item">
<span class="label">加入日期:</span>
<span class="value">{{ formatDate(user.joinDate) }}</span>
</div>
<div class="info-item">
<span class="label">最後登入:</span>
<span class="value" v-if="user.lastLogin">
{{ formatDate(user.lastLogin) }}
<small class="relative-time">({{ getRelativeTime(user.lastLogin) }})</small>
</span>
<span class="value no-data" v-else>從未登入</span>
</div>
</div>
</div>
<div class="info-card">
<h4>帳戶統計</h4>
<div class="stats-items">
<div class="stat-item">
<div class="stat-value">{{ user.loginCount || 0 }}</div>
<div class="stat-label">總登入次數</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ calculateAccountAge(user.joinDate) }}</div>
<div class="stat-label">帳戶年齡</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ user.projectCount || 0 }}</div>
<div class="stat-label">參與專案</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ user.taskCount || 0 }}</div>
<div class="stat-label">完成任務</div>
</div>
</div>
</div>
</div>
</TabPanel>
<!-- 權限管理 -->
<TabPanel header="權限管理" leftIcon="pi pi-shield">
<div class="permissions-overview">
<div class="permissions-summary">
<h4>權限總覽</h4>
<div class="permission-stats">
<div class="permission-stat">
<span class="stat-number">{{ user.permissions?.length || 0 }}</span>
<span class="stat-text">已授權權限</span>
</div>
<div class="permission-stat">
<span class="stat-number">{{ totalPermissions - (user.permissions?.length || 0) }}</span>
<span class="stat-text">未授權權限</span>
</div>
</div>
</div>
<div class="permissions-list">
<div class="permission-group" v-for="group in permissionGroups" :key="group.name">
<h5 class="permission-group-title">{{ group.label }}</h5>
<div class="permission-items">
<div
v-for="permission in group.permissions"
:key="permission.value"
class="permission-item"
>
<div class="permission-info">
<i
:class="hasPermission(permission.value) ? 'pi pi-check-circle permission-granted' : 'pi pi-times-circle permission-denied'"
></i>
<span class="permission-name">{{ permission.label }}</span>
</div>
<Tag
:value="hasPermission(permission.value) ? '已授權' : '未授權'"
:severity="hasPermission(permission.value) ? 'success' : 'secondary'"
class="permission-status"
/>
</div>
</div>
</div>
</div>
</div>
</TabPanel>
<!-- 活動記錄 -->
<TabPanel header="活動記錄" leftIcon="pi pi-history">
<div class="activity-log">
<div class="activity-filters">
<Dropdown
v-model="activityFilter"
:options="activityFilterOptions"
optionLabel="label"
optionValue="value"
placeholder="篩選活動類型"
class="activity-filter-dropdown"
/>
</div>
<Timeline
:value="filteredActivities"
align="left"
class="activity-timeline"
>
<template #marker="slotProps">
<div class="activity-marker">
<i :class="getActivityIcon(slotProps.item.type)"></i>
</div>
</template>
<template #content="slotProps">
<Card class="activity-card">
<template #content>
<div class="activity-content">
<div class="activity-header">
<h5 class="activity-title">{{ slotProps.item.title }}</h5>
<small class="activity-time">{{ formatDateTime(slotProps.item.timestamp) }}</small>
</div>
<p class="activity-description">{{ slotProps.item.description }}</p>
<div class="activity-meta" v-if="slotProps.item.metadata">
<div class="meta-item" v-for="(value, key) in slotProps.item.metadata" :key="key">
<span class="meta-key">{{ key }}:</span>
<span class="meta-value">{{ value }}</span>
</div>
</div>
</div>
</template>
</Card>
</template>
</Timeline>
<!-- 空狀態 -->
<div v-if="filteredActivities.length === 0" class="empty-activities">
<i class="pi pi-clock" style="font-size: 3rem; color: #cbd5e0;"></i>
<h3>暫無活動記錄</h3>
<p>此使用者目前沒有相關的活動記錄</p>
</div>
</div>
</TabPanel>
<!-- 安全設定 -->
<TabPanel header="安全設定" leftIcon="pi pi-lock">
<div class="security-overview">
<div class="security-status">
<h4>安全狀態概覽</h4>
<div class="security-items">
<div class="security-item">
<div class="security-icon">
<i :class="user.isEmailVerified ? 'pi pi-check-circle text-green' : 'pi pi-times-circle text-red'"></i>
</div>
<div class="security-content">
<h5>Email 驗證</h5>
<p>{{ user.isEmailVerified ? 'Email 地址已驗證' : 'Email 地址尚未驗證' }}</p>
</div>
<div class="security-action">
<Button
v-if="!user.isEmailVerified"
label="發送驗證郵件"
size="small"
class="p-button-outlined"
@click="sendVerificationEmail"
/>
</div>
</div>
<div class="security-item">
<div class="security-icon">
<i :class="user.twoFactorEnabled ? 'pi pi-check-circle text-green' : 'pi pi-times-circle text-red'"></i>
</div>
<div class="security-content">
<h5>兩步驟驗證</h5>
<p>{{ user.twoFactorEnabled ? '已啟用兩步驟驗證' : '尚未啟用兩步驟驗證' }}</p>
</div>
<div class="security-action">
<Button
:label="user.twoFactorEnabled ? '停用' : '啟用'"
size="small"
:class="user.twoFactorEnabled ? 'p-button-danger p-button-outlined' : 'p-button-success p-button-outlined'"
@click="toggleTwoFactor"
/>
</div>
</div>
<div class="security-item">
<div class="security-icon">
<i class="pi pi-key text-blue"></i>
</div>
<div class="security-content">
<h5>密碼</h5>
<p>最後變更:{{ user.passwordLastChanged ? formatDate(user.passwordLastChanged) : '未知' }}</p>
</div>
<div class="security-action">
<Button
label="強制重設密碼"
size="small"
class="p-button-warning p-button-outlined"
@click="forcePasswordReset"
/>
</div>
</div>
<div class="security-item">
<div class="security-icon">
<i class="pi pi-desktop text-blue"></i>
</div>
<div class="security-content">
<h5>活躍會話</h5>
<p>目前有 {{ user.activeSessions || 0 }} 個活躍會話</p>
</div>
<div class="security-action">
<Button
label="終止所有會話"
size="small"
class="p-button-danger p-button-outlined"
@click="terminateAllSessions"
/>
</div>
</div>
</div>
</div>
<div class="login-history">
<h4>登入記錄</h4>
<DataTable
:value="loginHistory"
:rows="5"
:paginator="true"
responsiveLayout="scroll"
class="login-history-table"
>
<Column field="timestamp" header="時間" style="width: 180px">
<template #body="{ data }">
{{ formatDateTime(data.timestamp) }}
</template>
</Column>
<Column field="ipAddress" header="IP 地址" style="width: 140px"></Column>
<Column field="userAgent" header="裝置/瀏覽器">
<template #body="{ data }">
<div class="device-info">
<i :class="getDeviceIcon(data.userAgent)"></i>
<span>{{ getDeviceDescription(data.userAgent) }}</span>
</div>
</template>
</Column>
<Column field="location" header="位置" style="width: 120px"></Column>
<Column field="status" header="狀態" style="width: 100px">
<template #body="{ data }">
<Tag
:value="data.status === 'success' ? '成功' : '失敗'"
:severity="data.status === 'success' ? 'success' : 'danger'"
/>
</template>
</Column>
</DataTable>
</div>
</div>
</TabPanel>
</TabView>
</div>
<!-- 底部操作區 -->
<div class="detail-footer">
<div class="footer-info">
<small class="last-updated">
最後更新:{{ formatDateTime(user.updatedAt || user.joinDate) }}
</small>
</div>
<div class="footer-actions">
<Button
label="關閉"
icon="pi pi-times"
@click="$emit('close')"
class="p-button-text"
/>
<Button
label="編輯使用者"
icon="pi pi-pencil"
@click="$emit('edit')"
/>
</div>
</div>
<!-- 確認對話框 -->
<ConfirmDialog />
<!-- Toast 通知 -->
<Toast />
</div>
</template>
<script>
import { ref, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { format, formatDistanceToNow } from 'date-fns'
import { zhTW } from 'date-fns/locale'
export default {
name: 'UserDetail',
props: {
user: {
type: Object,
required: true
}
},
emits: ['edit', 'close'],
setup(props) {
const toast = useToast()
const confirm = useConfirm()
const activityFilter = ref('all')
// 模擬資料
const activities = ref([
{
type: 'login',
title: '登入系統',
description: '使用者從 Chrome 瀏覽器登入系統',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
metadata: {
'IP 地址': '192.168.1.100',
'瀏覽器': 'Chrome 91.0'
}
},
{
type: 'profile',
title: '更新個人資料',
description: '修改了電話號碼',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
metadata: {
'變更欄位': '電話號碼'
}
}
])
const loginHistory = ref([
{
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
ipAddress: '192.168.1.100',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
location: '台北, 台灣',
status: 'success'
}
])
const permissionGroups = ref([
{
name: 'user',
label: '使用者管理',
permissions: [
{ label: '檢視使用者', value: 'user.view' },
{ label: '建立使用者', value: 'user.create' }
]
}
])
// 計算屬性和方法
const defaultAvatar = computed(() => {
if (props.user.firstName && props.user.lastName) {
return `https://ui-avatars.com/api/?name=${props.user.firstName}+${props.user.lastName}&background=random&size=150`
}
return 'https://via.placeholder.com/150x150?text=?'
})
// 其他方法省略(與前面相同)...
return {
// 返回所有需要的屬性和方法
activityFilter,
activities,
loginHistory,
permissionGroups,
defaultAvatar,
// 其他方法...
}
}
}
</script>
<style scoped>
/* UserDetail 樣式定義 */
.user-detail {
height: 70vh;
display: flex;
flex-direction: column;
}
.detail-content {
flex: 1;
overflow: hidden;
}
.user-header {
display: flex;
gap: 2rem;
align-items: flex-start;
padding: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
margin-bottom: 1rem;
}
/* 其他樣式省略... */
</style>
3.2 第三章總結與學習重點
🎯 專案完成度檢查
✅ 已實現功能:
完整的使用者管理系統架構
- 資料模型設計
- Pinia 狀態管理
- API 服務層
- 元件化架構
豐富的使用者介面
- 統計卡片顯示
- 進階篩選和搜尋
- 資料表格展示
- 分頁和排序
完整的 CRUD 操作
- 新增使用者
- 編輯使用者資料
- 刪除和批次刪除
- 檢視使用者詳情
進階功能實現
- 權限管理
- 頭像上傳
- 表單驗證
- 活動記錄
- 安全設定
響應式設計
- 適配各種螢幕尺寸
- 行動裝置友善
- 無障礙支援
📚 技術重點回顧
狀態管理模式
// Pinia Store 的最佳實務 - 分層架構:Actions -> API -> State - 計算屬性的有效使用 - 響應式資料流管理
元件通訊策略
// Props down, Events up 原則 - 父子元件通訊 - 事件發射和監聽 - 狀態提升到共同父元件
表單處理技巧
// 複雜表單的管理 - 表單驗證邏輯 - 條件式顯示 - 檔案上傳處理 - 動態選項載入
資料表格優化
// DataTable 進階應用 - 虛擬滾動 - 自訂欄位範本 - 多重選擇 - 排序和篩選
🔧 實務開發技巧
程式碼組織
- 邏輯分離:將業務邏輯從元件中抽離
- 可重用性:建立通用的工具函數
- 可維護性:清晰的註解和命名規範
效能優化
- 懶載入:按需載入元件和資料
- 快取策略:適當的資料快取
- 虛擬化:大量資料的虛擬滾動
使用者體驗
- 載入狀態:適當的載入指示
- 錯誤處理:友善的錯誤訊息
- 回饋機制:操作成功的確認
安全考量
- 輸入驗證:前後端雙重驗證
- 權限控制:基於角色的訪問控制
- 資料保護:敏感資料的處理
💡 延伸學習建議
進階 PrimeVue 功能
- 自訂主題開發
- 複雜動畫效果
- 國際化支援
測試策略
- 單元測試
- 整合測試
- E2E 測試
效能監控
- 載入時間分析
- 記憶體使用監控
- 使用者行為追蹤
🏆 第三章學習成果
透過本章的實戰專案,您已經:
- ✅ 掌握了大型 Vue 應用程式的架構設計
- ✅ 學會了 PrimeVue 元件的深度整合
- ✅ 理解了狀態管理的最佳實務
- ✅ 實現了完整的企業級功能
- ✅ 具備了解決實際業務問題的能力
準備好迎接第四章「進階功能與效能優化」的挑戰了嗎?
第四章:進階功能與效能優化
4.1 效能優化策略
在前面的章節中,我們建立了完整的使用者管理系統。現在讓我們學習如何優化應用程式的效能,確保良好的使用者體驗。
4.1.1 Vue 3 的效能優化特性
組合式 API 的效能優勢
// 使用 computed 進行計算屬性優化
import { computed, ref } from 'vue'
export default {
setup() {
const users = ref([])
const searchKeyword = ref('')
// 計算屬性自動快取,只有依賴變更時才重新計算
const filteredUsers = computed(() => {
if (!searchKeyword.value) return users.value
return users.value.filter(user =>
user.firstName.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
user.lastName.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
user.email.toLowerCase().includes(searchKeyword.value.toLowerCase())
)
})
return {
users,
searchKeyword,
filteredUsers
}
}
}
響應式系統優化
// 使用 shallowRef 和 shallowReactive 優化大型物件
import { shallowRef, shallowReactive, triggerRef } from 'vue'
export default {
setup() {
// 對於只需要替換整個物件的場景
const largeDataSet = shallowRef([])
// 對於大型的設定物件
const appConfig = shallowReactive({
theme: 'light',
language: 'zh-TW',
features: {
// 深層物件不會被響應式處理
notifications: true,
analytics: false
}
})
const updateDataSet = (newData) => {
largeDataSet.value = newData
triggerRef(largeDataSet) // 手動觸發更新
}
return {
largeDataSet,
appConfig,
updateDataSet
}
}
}
4.1.2 PrimeVue 元件效能優化
DataTable 虛擬滾動
<template>
<div class="optimized-data-table">
<!-- 虛擬滾動提升大量資料的渲染效能 -->
<DataTable
:value="users"
:virtualScrollerOptions="virtualScrollerOptions"
:rows="50"
scrollable
scrollHeight="600px"
:loading="loading"
dataKey="id"
:lazy="true"
@page="onPage"
@sort="onSort"
@filter="onFilter"
>
<Column field="id" header="ID" sortable style="width: 80px" />
<Column field="firstName" header="名字" sortable>
<template #body="{ data }">
<!-- 使用 v-memo 優化重複渲染 -->
<span v-memo="[data.firstName, data.status]">
{{ data.firstName }}
<Tag
v-if="data.status === 'vip'"
value="VIP"
severity="success"
/>
</span>
</template>
</Column>
<Column field="email" header="Email" sortable />
<Column header="操作" style="width: 120px">
<template #body="{ data }">
<!-- 使用事件委派減少監聽器數量 -->
<div class="action-buttons" @click="handleAction($event, data)">
<Button
icon="pi pi-pencil"
class="p-button-text p-button-sm"
data-action="edit"
/>
<Button
icon="pi pi-trash"
class="p-button-text p-button-sm p-button-danger"
data-action="delete"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
export default {
name: 'OptimizedDataTable',
setup() {
const users = ref([])
const loading = ref(false)
const totalRecords = ref(0)
const lazyParams = ref({
first: 0,
rows: 50,
sortField: null,
sortOrder: null,
filters: null
})
// 虛擬滾動設定
const virtualScrollerOptions = ref({
itemSize: 46, // 每行高度
autoSize: true,
lazy: true,
delay: 200,
showLoader: true,
numToleratedItems: 20
})
// 事件委派處理操作
const handleAction = (event, data) => {
const action = event.target.closest('[data-action]')?.dataset.action
switch (action) {
case 'edit':
editUser(data)
break
case 'delete':
deleteUser(data)
break
}
}
// 懶載入資料
const loadLazyData = async () => {
loading.value = true
try {
// 模擬 API 調用
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(lazyParams.value)
})
const result = await response.json()
users.value = result.data
totalRecords.value = result.totalRecords
} finally {
loading.value = false
}
}
const onPage = (event) => {
lazyParams.value.first = event.first
lazyParams.value.rows = event.rows
loadLazyData()
}
const onSort = (event) => {
lazyParams.value.sortField = event.sortField
lazyParams.value.sortOrder = event.sortOrder
loadLazyData()
}
const onFilter = (event) => {
lazyParams.value.filters = event.filters
lazyParams.value.first = 0
loadLazyData()
}
onMounted(() => {
loadLazyData()
})
return {
users,
loading,
totalRecords,
virtualScrollerOptions,
handleAction,
onPage,
onSort,
onFilter
}
}
}
</script>
動態元件載入
<template>
<div class="dynamic-components">
<!-- 使用 Suspense 和動態導入 -->
<Suspense>
<template #default>
<component :is="currentComponent" v-bind="componentProps" />
</template>
<template #fallback>
<div class="loading-placeholder">
<ProgressSpinner />
<p>載入元件中...</p>
</div>
</template>
</Suspense>
</div>
</template>
<script>
import { ref, computed, defineAsyncComponent } from 'vue'
import { ProgressSpinner } from 'primevue/progressspinner'
export default {
name: 'DynamicComponentLoader',
components: {
ProgressSpinner
},
props: {
componentName: String,
componentProps: Object
},
setup(props) {
// 動態載入元件的工廠函數
const componentRegistry = {
UserList: () => import('@/components/UserList.vue'),
UserForm: () => import('@/components/UserForm.vue'),
UserDetail: () => import('@/components/UserDetail.vue'),
Analytics: () => import('@/components/Analytics.vue')
}
const currentComponent = computed(() => {
if (!props.componentName || !componentRegistry[props.componentName]) {
return null
}
return defineAsyncComponent({
loader: componentRegistry[props.componentName],
delay: 200,
timeout: 10000,
errorComponent: {
template: `
<div class="error-component">
<i class="pi pi-exclamation-triangle"></i>
<p>元件載入失敗</p>
</div>
`
},
loadingComponent: {
template: `
<div class="loading-component">
<ProgressSpinner />
</div>
`
}
})
})
return {
currentComponent
}
}
}
</script>
4.1.3 記憶體管理與清理
適當的事件監聽器清理
// composables/useEventListener.js
import { onBeforeUnmount, onMounted } from 'vue'
export function useEventListener(target, event, callback, options = {}) {
let cleanup = () => {}
onMounted(() => {
const element = typeof target === 'string'
? document.querySelector(target)
: target
if (element) {
element.addEventListener(event, callback, options)
cleanup = () => element.removeEventListener(event, callback, options)
}
})
onBeforeUnmount(cleanup)
return cleanup
}
// 使用範例
export default {
setup() {
const handleResize = () => {
// 處理視窗大小變更
}
// 自動清理事件監聽器
useEventListener(window, 'resize', handleResize)
const handleScroll = throttle(() => {
// 處理滾動事件
}, 100)
useEventListener(window, 'scroll', handleScroll, { passive: true })
}
}
Intersection Observer 的使用
// composables/useIntersectionObserver.js
import { ref, onBeforeUnmount } from 'vue'
export function useIntersectionObserver(callback, options = {}) {
const target = ref(null)
const isIntersecting = ref(false)
let observer = null
const observe = () => {
if (target.value && 'IntersectionObserver' in window) {
observer = new IntersectionObserver(([entry]) => {
isIntersecting.value = entry.isIntersecting
callback(entry)
}, options)
observer.observe(target.value)
}
}
const unobserve = () => {
if (observer) {
observer.disconnect()
observer = null
}
}
onBeforeUnmount(unobserve)
return {
target,
isIntersecting,
observe,
unobserve
}
}
// 無限滾動範例
export default {
setup() {
const items = ref([])
const loading = ref(false)
const loadMore = async () => {
if (loading.value) return
loading.value = true
try {
const newItems = await fetchMoreItems()
items.value.push(...newItems)
} finally {
loading.value = false
}
}
const { target: loadTrigger, observe } = useIntersectionObserver(
(entry) => {
if (entry.isIntersecting) {
loadMore()
}
},
{ threshold: 0.1 }
)
onMounted(observe)
return {
items,
loading,
loadTrigger
}
}
}
4.2 國際化 (i18n) 實作
4.2.1 Vue I18n 設定
基本設定
// i18n/index.js
import { createI18n } from 'vue-i18n'
import zhTW from './locales/zh-TW.json'
import enUS from './locales/en-US.json'
import jaJP from './locales/ja-JP.json'
const messages = {
'zh-TW': zhTW,
'en-US': enUS,
'ja-JP': jaJP
}
// 檢測瀏覽器語言
const getBrowserLocale = () => {
const locale = navigator.language || navigator.languages[0]
// 對應到支援的語言
const supportedLocales = Object.keys(messages)
const matchedLocale = supportedLocales.find(supported =>
locale.startsWith(supported.split('-')[0])
)
return matchedLocale || 'zh-TW'
}
const i18n = createI18n({
legacy: false, // 使用 Composition API 模式
locale: localStorage.getItem('app-locale') || getBrowserLocale(),
fallbackLocale: 'zh-TW',
messages,
globalInjection: true,
silentFallbackWarn: true,
missingWarn: false,
silentTranslationWarn: true
})
export default i18n
語言包結構
// i18n/locales/zh-TW.json
{
"common": {
"yes": "是",
"no": "否",
"save": "儲存",
"cancel": "取消",
"delete": "刪除",
"edit": "編輯",
"add": "新增",
"search": "搜尋",
"loading": "載入中...",
"noData": "暫無資料",
"confirm": "確認",
"warning": "警告",
"error": "錯誤",
"success": "成功"
},
"navigation": {
"dashboard": "儀表板",
"users": "使用者管理",
"settings": "設定",
"profile": "個人資料",
"logout": "登出"
},
"user": {
"title": "使用者管理",
"list": "使用者列表",
"add": "新增使用者",
"edit": "編輯使用者",
"detail": "使用者詳情",
"firstName": "名字",
"lastName": "姓氏",
"email": "電子郵件",
"phone": "電話號碼",
"role": "角色",
"department": "部門",
"status": "狀態",
"joinDate": "加入日期",
"lastLogin": "最後登入",
"actions": "操作",
"deleteConfirm": "確定要刪除此使用者嗎?",
"deleteSelected": "刪除選取的使用者",
"totalUsers": "總使用者數",
"activeUsers": "活躍使用者",
"pendingUsers": "待審核使用者"
},
"validation": {
"required": "此欄位為必填",
"email": "請輸入有效的電子郵件地址",
"minLength": "最少需要 {min} 個字元",
"maxLength": "最多只能 {max} 個字元",
"pattern": "格式不正確"
},
"message": {
"saveSuccess": "儲存成功",
"deleteSuccess": "刪除成功",
"updateSuccess": "更新成功",
"operationFailed": "操作失敗",
"networkError": "網路連線錯誤",
"permissionDenied": "權限不足"
}
}
4.2.2 在元件中使用國際化
基本使用方式
<template>
<div class="user-management">
<div class="page-header">
<h1>{{ $t('user.title') }}</h1>
<Button
:label="$t('user.add')"
icon="pi pi-plus"
@click="showAddDialog = true"
/>
</div>
<Card class="stats-card">
<template #content>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{{ totalUsers }}</div>
<div class="stat-label">{{ $t('user.totalUsers') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ activeUsers }}</div>
<div class="stat-label">{{ $t('user.activeUsers') }}</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pendingUsers }}</div>
<div class="stat-label">{{ $t('user.pendingUsers') }}</div>
</div>
</div>
</template>
</Card>
<DataTable
:value="users"
:loading="loading"
dataKey="id"
:paginator="true"
:rows="20"
:emptyMessage="$t('common.noData')"
>
<Column field="firstName" :header="$t('user.firstName')" sortable />
<Column field="email" :header="$t('user.email')" sortable />
<Column field="role" :header="$t('user.role')">
<template #body="{ data }">
<Tag :value="$t(`role.${data.role}`)" />
</template>
</Column>
<Column :header="$t('user.actions')">
<template #body="{ data }">
<Button
:label="$t('common.edit')"
icon="pi pi-pencil"
class="p-button-text"
@click="editUser(data)"
/>
<Button
:label="$t('common.delete')"
icon="pi pi-trash"
class="p-button-text p-button-danger"
@click="confirmDelete(data)"
/>
</template>
</Column>
</DataTable>
<!-- 刪除確認對話框 -->
<ConfirmDialog
:header="$t('common.warning')"
:message="$t('user.deleteConfirm')"
:acceptLabel="$t('common.yes')"
:rejectLabel="$t('common.no')"
/>
</div>
</template>
<script>
import { useI18n } from 'vue-i18n'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
export default {
name: 'UserManagement',
setup() {
const { t } = useI18n()
const confirm = useConfirm()
const toast = useToast()
const confirmDelete = (user) => {
confirm.require({
message: t('user.deleteConfirm'),
header: t('common.warning'),
icon: 'pi pi-exclamation-triangle',
accept: () => {
deleteUser(user)
}
})
}
const deleteUser = async (user) => {
try {
await userStore.deleteUser(user.id)
toast.add({
severity: 'success',
summary: t('common.success'),
detail: t('message.deleteSuccess'),
life: 3000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('common.error'),
detail: t('message.operationFailed'),
life: 3000
})
}
}
return {
confirmDelete
}
}
}
</script>
動態語言切換
<template>
<div class="language-switcher">
<Dropdown
v-model="currentLocale"
:options="localeOptions"
optionLabel="name"
optionValue="code"
@change="changeLanguage"
class="language-dropdown"
>
<template #option="{ option }">
<div class="locale-option">
<img :src="option.flag" :alt="option.name" class="flag-icon" />
<span>{{ option.name }}</span>
</div>
</template>
<template #value="{ value }">
<div class="locale-option" v-if="value">
<img :src="getLocaleFlag(value)" :alt="getLocaleName(value)" class="flag-icon" />
<span>{{ getLocaleName(value) }}</span>
</div>
</template>
</Dropdown>
</div>
</template>
<script>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
export default {
name: 'LanguageSwitcher',
setup() {
const { locale, availableLocales } = useI18n()
const localeOptions = ref([
{
code: 'zh-TW',
name: '繁體中文',
flag: '/images/flags/tw.svg'
},
{
code: 'en-US',
name: 'English',
flag: '/images/flags/us.svg'
},
{
code: 'ja-JP',
name: '日本語',
flag: '/images/flags/jp.svg'
}
])
const currentLocale = computed({
get: () => locale.value,
set: (value) => {
locale.value = value
}
})
const changeLanguage = (event) => {
const newLocale = event.value
// 儲存使用者選擇
localStorage.setItem('app-locale', newLocale)
// 更新文件語言屬性
document.documentElement.lang = newLocale
// 可以在這裡觸發其他需要重新載入的操作
// 如重新載入某些依賴語言的資料
}
const getLocaleFlag = (code) => {
return localeOptions.value.find(option => option.code === code)?.flag
}
const getLocaleName = (code) => {
return localeOptions.value.find(option => option.code === code)?.name
}
return {
currentLocale,
localeOptions,
changeLanguage,
getLocaleFlag,
getLocaleName
}
}
}
</script>
<style scoped>
.language-dropdown {
min-width: 150px;
}
.locale-option {
display: flex;
align-items: center;
gap: 0.5rem;
}
.flag-icon {
width: 20px;
height: 15px;
object-fit: cover;
border-radius: 2px;
}
</style>
4.2.3 PrimeVue 元件的本地化
設定 PrimeVue 的語言包
// main.js
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
import i18n from './i18n'
// PrimeVue 語言包
import zhTW from 'primevue/resources/locale/zh-tw.json'
import enUS from 'primevue/resources/locale/en.json'
import jaJP from 'primevue/resources/locale/ja.json'
const app = createApp(App)
app.use(PrimeVue, {
locale: zhTW // 預設語言
})
app.use(i18n)
// 監聽語言變更,更新 PrimeVue 語言包
app.config.globalProperties.$primevue.config.locale = zhTW
app.mount('#app')
建立語言切換 Composable
// composables/useLocale.js
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePrimeVue } from 'primevue/config'
// PrimeVue 語言包對應
const primeVueLocales = {
'zh-TW': () => import('primevue/resources/locale/zh-tw.json'),
'en-US': () => import('primevue/resources/locale/en.json'),
'ja-JP': () => import('primevue/resources/locale/ja.json')
}
export function useLocale() {
const { locale, t } = useI18n()
const primevue = usePrimeVue()
const currentLocale = computed(() => locale.value)
const changeLocale = async (newLocale) => {
// 更新 Vue I18n
locale.value = newLocale
// 更新 PrimeVue 語言包
if (primeVueLocales[newLocale]) {
const localeData = await primeVueLocales[newLocale]()
primevue.config.locale = localeData.default || localeData
}
// 儲存到本地存儲
localStorage.setItem('app-locale', newLocale)
// 更新 HTML lang 屬性
document.documentElement.lang = newLocale
}
const formatCurrency = (value, currency = 'TWD') => {
return new Intl.NumberFormat(locale.value, {
style: 'currency',
currency
}).format(value)
}
const formatDate = (date, options = {}) => {
const defaultOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
}
return new Intl.DateTimeFormat(locale.value, {
...defaultOptions,
...options
}).format(new Date(date))
}
const formatNumber = (value, options = {}) => {
return new Intl.NumberFormat(locale.value, options).format(value)
}
return {
currentLocale,
changeLocale,
t,
formatCurrency,
formatDate,
formatNumber
}
}
4.3 主題系統與自訂樣式
4.3.1 PrimeVue 主題系統
PrimeVue 提供了完整的主題系統,支援多種預設主題和自訂主題開發。
主題配置
// main.js
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
// 主題樣式
import 'primevue/resources/themes/lara-light-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css'
const app = createApp(App)
app.use(PrimeVue)
動態主題切換
<template>
<div class="theme-switcher">
<div class="theme-controls">
<h3>主題設定</h3>
<!-- 主題選擇 -->
<div class="theme-section">
<label>選擇主題:</label>
<Dropdown
v-model="selectedTheme"
:options="themeOptions"
optionLabel="name"
optionValue="id"
@change="changeTheme"
class="theme-dropdown"
>
<template #option="{ option }">
<div class="theme-option">
<div
class="theme-preview"
:style="{ backgroundColor: option.primary }"
></div>
<span>{{ option.name }}</span>
</div>
</template>
</Dropdown>
</div>
<!-- 暗黑模式切換 -->
<div class="theme-section">
<label>暗黑模式:</label>
<InputSwitch
v-model="isDarkMode"
@change="toggleDarkMode"
/>
</div>
<!-- 自訂顏色 -->
<div class="theme-section">
<label>主要色彩:</label>
<ColorPicker
v-model="customPrimaryColor"
@change="updateCustomColor"
format="hex"
/>
</div>
<!-- 字體大小 -->
<div class="theme-section">
<label>字體大小:</label>
<Slider
v-model="fontSize"
:min="12"
:max="20"
@change="updateFontSize"
class="font-size-slider"
/>
<span class="font-size-value">{{ fontSize }}px</span>
</div>
<!-- 間距密度 -->
<div class="theme-section">
<label>介面密度:</label>
<SelectButton
v-model="density"
:options="densityOptions"
optionLabel="name"
optionValue="value"
@change="updateDensity"
/>
</div>
</div>
<!-- 主題預覽 -->
<div class="theme-preview-panel">
<h3>預覽</h3>
<div class="preview-content">
<Card class="preview-card">
<template #header>
<img src="/demo/card-header.jpg" alt="header" />
</template>
<template #title>卡片標題</template>
<template #content>
<p>這是一個卡片內容的範例,用來展示當前主題的外觀效果。</p>
<div class="preview-buttons">
<Button label="主要按鈕" />
<Button label="次要按鈕" class="p-button-secondary" />
<Button label="成功按鈕" class="p-button-success" />
</div>
</template>
</Card>
<DataTable
:value="previewData"
class="preview-table"
>
<Column field="name" header="名稱" />
<Column field="category" header="類別">
<template #body="{ data }">
<Tag :value="data.category" />
</template>
</Column>
<Column field="status" header="狀態">
<template #body="{ data }">
<Tag
:value="data.status"
:severity="getStatusSeverity(data.status)"
/>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, watch } from 'vue'
export default {
name: 'ThemeSwitcher',
setup() {
const selectedTheme = ref('lara-light-blue')
const isDarkMode = ref(false)
const customPrimaryColor = ref('#3B82F6')
const fontSize = ref(14)
const density = ref('normal')
const themeOptions = ref([
{
id: 'lara-light-blue',
name: 'Lara Light Blue',
primary: '#3B82F6'
},
{
id: 'lara-light-indigo',
name: 'Lara Light Indigo',
primary: '#6366F1'
},
{
id: 'lara-light-purple',
name: 'Lara Light Purple',
primary: '#8B5CF6'
},
{
id: 'lara-light-teal',
name: 'Lara Light Teal',
primary: '#14B8A6'
},
{
id: 'saga-blue',
name: 'Saga Blue',
primary: '#007bff'
},
{
id: 'nova',
name: 'Nova',
primary: '#007ad9'
}
])
const densityOptions = ref([
{ name: '緊密', value: 'compact' },
{ name: '正常', value: 'normal' },
{ name: '寬鬆', value: 'comfortable' }
])
const previewData = ref([
{ name: '產品 A', category: '電子產品', status: '有庫存' },
{ name: '產品 B', category: '服飾', status: '缺貨' },
{ name: '產品 C', category: '書籍', status: '有庫存' }
])
// 載入儲存的主題設定
const loadThemeSettings = () => {
const savedSettings = JSON.parse(localStorage.getItem('themeSettings') || '{}')
selectedTheme.value = savedSettings.theme || 'lara-light-blue'
isDarkMode.value = savedSettings.darkMode || false
customPrimaryColor.value = savedSettings.primaryColor || '#3B82F6'
fontSize.value = savedSettings.fontSize || 14
density.value = savedSettings.density || 'normal'
applyTheme()
}
// 儲存主題設定
const saveThemeSettings = () => {
const settings = {
theme: selectedTheme.value,
darkMode: isDarkMode.value,
primaryColor: customPrimaryColor.value,
fontSize: fontSize.value,
density: density.value
}
localStorage.setItem('themeSettings', JSON.stringify(settings))
}
// 應用主題
const applyTheme = () => {
const themeLink = document.getElementById('theme-link')
if (themeLink) {
const themeName = isDarkMode.value
? selectedTheme.value.replace('light', 'dark')
: selectedTheme.value
themeLink.href = `/themes/${themeName}/theme.css`
}
// 應用自訂樣式
applyCustomStyles()
}
// 應用自訂樣式
const applyCustomStyles = () => {
const root = document.documentElement
// 設定主要色彩
root.style.setProperty('--primary-color', customPrimaryColor.value)
// 設定字體大小
root.style.setProperty('--base-font-size', `${fontSize.value}px`)
// 設定密度
const densityValues = {
compact: '0.5rem',
normal: '1rem',
comfortable: '1.5rem'
}
root.style.setProperty('--content-padding', densityValues[density.value])
// 設定暗黑模式類別
if (isDarkMode.value) {
document.body.classList.add('p-dark')
} else {
document.body.classList.remove('p-dark')
}
}
// 事件處理
const changeTheme = () => {
applyTheme()
saveThemeSettings()
}
const toggleDarkMode = () => {
applyTheme()
saveThemeSettings()
}
const updateCustomColor = () => {
applyCustomStyles()
saveThemeSettings()
}
const updateFontSize = () => {
applyCustomStyles()
saveThemeSettings()
}
const updateDensity = () => {
applyCustomStyles()
saveThemeSettings()
}
const getStatusSeverity = (status) => {
const severityMap = {
'有庫存': 'success',
'缺貨': 'danger',
'預購': 'warning'
}
return severityMap[status] || 'info'
}
// 監聽主題變更
watch([selectedTheme, isDarkMode], () => {
applyTheme()
})
onMounted(() => {
loadThemeSettings()
})
return {
selectedTheme,
isDarkMode,
customPrimaryColor,
fontSize,
density,
themeOptions,
densityOptions,
previewData,
changeTheme,
toggleDarkMode,
updateCustomColor,
updateFontSize,
updateDensity,
getStatusSeverity
}
}
}
</script>
<style scoped>
.theme-switcher {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 2rem;
padding: 2rem;
}
.theme-controls {
background: var(--surface-card);
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.theme-section {
margin-bottom: 2rem;
}
.theme-section label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-color);
}
.theme-dropdown {
width: 100%;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.75rem;
}
.theme-preview {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--surface-border);
}
.font-size-slider {
width: 100%;
margin-bottom: 0.5rem;
}
.font-size-value {
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.theme-preview-panel {
background: var(--surface-card);
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.preview-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.preview-card {
max-width: 400px;
}
.preview-buttons {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.preview-table {
max-width: 100%;
}
@media (max-width: 768px) {
.theme-switcher {
grid-template-columns: 1fr;
}
.preview-buttons {
flex-direction: column;
}
}
</style>
4.3.2 自訂 CSS 變數系統
建立設計令牌系統
/* styles/design-tokens.css */
:root {
/* 顏色系統 */
--primary-50: #eff6ff;
--primary-100: #dbeafe;
--primary-200: #bfdbfe;
--primary-300: #93c5fd;
--primary-400: #60a5fa;
--primary-500: #3b82f6;
--primary-600: #2563eb;
--primary-700: #1d4ed8;
--primary-800: #1e40af;
--primary-900: #1e3a8a;
/* 灰階系統 */
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-400: #9ca3af;
--gray-500: #6b7280;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
/* 語意化顏色 */
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--info-color: #3b82f6;
/* 字體系統 */
--font-family-sans: 'Inter', 'Noto Sans TC', sans-serif;
--font-family-mono: 'Fira Code', 'Monaco', monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* 間距系統 */
--spacing-0: 0;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
/* 陰影系統 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* 圓角系統 */
--radius-sm: 0.125rem;
--radius-base: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-full: 9999px;
/* 過渡動畫 */
--transition-fast: 150ms ease-in-out;
--transition-base: 250ms ease-in-out;
--transition-slow: 350ms ease-in-out;
/* Z-index 層級 */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
--z-toast: 1080;
}
/* 暗黑模式變數 */
.p-dark {
--surface-ground: #1a1a1a;
--surface-section: #2a2a2a;
--surface-card: #323232;
--surface-overlay: #424242;
--surface-border: #525252;
--surface-hover: #3a3a3a;
--text-color: #e5e5e5;
--text-color-secondary: #a3a3a3;
--primary-color: var(--primary-400);
--primary-color-text: #1a1a1a;
}
元件樣式覆蓋
/* styles/primevue-overrides.css */
/* Button 自訂樣式 */
.p-button {
font-weight: var(--font-weight-medium);
border-radius: var(--radius-md);
transition: var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.p-button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.p-button.p-button-rounded {
border-radius: var(--radius-full);
}
/* Card 自訂樣式 */
.p-card {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-base);
transition: var(--transition-base);
border: 1px solid var(--surface-border);
}
.p-card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.p-card .p-card-header {
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
/* DataTable 自訂樣式 */
.p-datatable {
border-radius: var(--radius-lg);
overflow: hidden;
}
.p-datatable .p-datatable-header {
background: var(--surface-section);
border-bottom: 2px solid var(--primary-color);
padding: var(--spacing-4);
}
.p-datatable .p-datatable-tbody > tr {
transition: var(--transition-fast);
}
.p-datatable .p-datatable-tbody > tr:hover {
background: var(--surface-hover);
}
.p-datatable .p-column-header-content {
font-weight: var(--font-weight-semibold);
color: var(--text-color);
}
/* Dialog 自訂樣式 */
.p-dialog {
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
}
.p-dialog .p-dialog-header {
background: var(--surface-section);
border-bottom: 1px solid var(--surface-border);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
.p-dialog .p-dialog-content {
padding: var(--spacing-6);
}
/* Toast 自訂樣式 */
.p-toast .p-toast-message {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
}
.p-toast .p-toast-message.p-toast-message-success {
background: rgba(16, 185, 129, 0.95);
border-left: 4px solid var(--success-color);
}
.p-toast .p-toast-message.p-toast-message-error {
background: rgba(239, 68, 68, 0.95);
border-left: 4px solid var(--danger-color);
}
/* InputText 自訂樣式 */
.p-inputtext {
border-radius: var(--radius-md);
transition: var(--transition-fast);
font-size: var(--font-size-base);
}
.p-inputtext:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
border-color: var(--primary-color);
}
/* Dropdown 自訂樣式 */
.p-dropdown {
border-radius: var(--radius-md);
transition: var(--transition-fast);
}
.p-dropdown:not(.p-disabled):hover {
border-color: var(--primary-color);
}
.p-dropdown-panel {
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
border: 1px solid var(--surface-border);
}
/* Tag 自訂樣式 */
.p-tag {
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
padding: var(--spacing-1) var(--spacing-3);
}
/* 自訂工具類別 */
.glass-effect {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.gradient-primary {
background: linear-gradient(135deg, var(--primary-500), var(--primary-600));
}
.gradient-success {
background: linear-gradient(135deg, var(--success-color), #059669);
}
.gradient-warning {
background: linear-gradient(135deg, var(--warning-color), #d97706);
}
.gradient-danger {
background: linear-gradient(135deg, var(--danger-color), #dc2626);
}
.text-gradient {
background: linear-gradient(135deg, var(--primary-500), var(--primary-700));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* 響應式工具 */
@media (max-width: 768px) {
:root {
--font-size-base: 0.9rem;
--spacing-4: 0.75rem;
--spacing-6: 1rem;
}
}
/* 高對比度支援 */
@media (prefers-contrast: high) {
:root {
--surface-border: #000;
--text-color: #000;
}
.p-dark {
--surface-border: #fff;
--text-color: #fff;
}
}
/* 減少動畫(accessibility) */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
4.4 測試策略
4.4.1 單元測試設定
Vitest 配置
// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.js']
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
測試設定檔案
// src/test/setup.js
import { config } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
// 全域測試設定
config.global.plugins = [PrimeVue]
// 模擬 Pinia
config.global.plugins.push(
createTestingPinia({
createSpy: vi.fn
})
)
// 模擬 router
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn()
}
config.global.mocks = {
$router: mockRouter,
$route: {
path: '/',
params: {},
query: {}
}
}
// 模擬 localStorage
Object.defineProperty(window, 'localStorage', {
value: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
}
})
元件測試範例
// src/components/__tests__/UserList.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import UserList from '@/components/UserList.vue'
import { useUserStore } from '@/stores/userStore'
describe('UserList', () => {
let wrapper
let userStore
const mockUsers = [
{
id: 1,
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
role: 'admin',
status: 'active'
},
{
id: 2,
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com',
role: 'user',
status: 'inactive'
}
]
beforeEach(() => {
wrapper = mount(UserList, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
})
]
}
})
userStore = useUserStore()
userStore.users = mockUsers
userStore.loading = false
})
afterEach(() => {
wrapper.unmount()
})
it('應該正確渲染使用者列表', () => {
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('[data-testid="user-list"]').exists()).toBe(true)
})
it('應該顯示正確的使用者數量', () => {
const rows = wrapper.findAll('[data-testid="user-row"]')
expect(rows).toHaveLength(2)
})
it('應該正確顯示使用者資訊', () => {
const firstRow = wrapper.find('[data-testid="user-row"]:first-child')
expect(firstRow.text()).toContain('John Doe')
expect(firstRow.text()).toContain('john@example.com')
})
it('應該在點擊編輯按鈕時觸發編輯事件', async () => {
const editButton = wrapper.find('[data-testid="edit-button"]')
await editButton.trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')[0]).toEqual([mockUsers[0]])
})
it('應該在點擊刪除按鈕時顯示確認對話框', async () => {
const deleteButton = wrapper.find('[data-testid="delete-button"]')
await deleteButton.trigger('click')
// 等待下一個 tick 讓對話框渲染
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(true)
})
it('應該正確過濾使用者', async () => {
const searchInput = wrapper.find('[data-testid="search-input"]')
await searchInput.setValue('John')
// 觸發搜尋
await searchInput.trigger('input')
// 檢查過濾結果
expect(userStore.filterUsers).toHaveBeenCalledWith('John')
})
it('應該正確處理載入狀態', async () => {
userStore.loading = true
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-testid="loading-spinner"]').exists()).toBe(true)
})
it('應該正確處理空資料狀態', async () => {
userStore.users = []
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true)
expect(wrapper.text()).toContain('暫無使用者資料')
})
it('應該正確處理錯誤狀態', async () => {
userStore.error = '載入失敗'
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-testid="error-message"]').exists()).toBe(true)
expect(wrapper.text()).toContain('載入失敗')
})
})
4.4.2 整合測試
API 整合測試
// src/services/__tests__/userService.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { userService } from '@/services/userService'
// 模擬 fetch API
global.fetch = vi.fn()
describe('UserService', () => {
beforeEach(() => {
fetch.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('fetchUsers', () => {
it('應該成功獲取使用者列表', async () => {
const mockUsers = [
{ id: 1, firstName: 'John', lastName: 'Doe' },
{ id: 2, firstName: 'Jane', lastName: 'Smith' }
]
fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: mockUsers, total: 2 })
})
const result = await userService.fetchUsers()
expect(fetch).toHaveBeenCalledWith('/api/users', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
expect(result.data).toEqual(mockUsers)
expect(result.total).toBe(2)
})
it('應該正確處理 API 錯誤', async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: 'Internal Server Error'
})
await expect(userService.fetchUsers()).rejects.toThrow('Internal Server Error')
})
it('應該正確處理網路錯誤', async () => {
fetch.mockRejectedValueOnce(new Error('Network Error'))
await expect(userService.fetchUsers()).rejects.toThrow('Network Error')
})
})
describe('createUser', () => {
it('應該成功建立使用者', async () => {
const newUser = {
firstName: 'New',
lastName: 'User',
email: 'new@example.com'
}
const createdUser = { id: 3, ...newUser }
fetch.mockResolvedValueOnce({
ok: true,
json: async () => createdUser
})
const result = await userService.createUser(newUser)
expect(fetch).toHaveBeenCalledWith('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newUser)
})
expect(result).toEqual(createdUser)
})
it('應該正確處理驗證錯誤', async () => {
fetch.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({
message: 'Validation Error',
errors: {
email: 'Email is required'
}
})
})
await expect(userService.createUser({})).rejects.toThrow('Validation Error')
})
})
})
4.4.3 E2E 測試
Playwright 設定
// playwright.config.js
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI
}
})
E2E 測試範例
// e2e/user-management.spec.js
import { test, expect } from '@playwright/test'
test.describe('使用者管理', () => {
test.beforeEach(async ({ page }) => {
// 模擬登入
await page.goto('/login')
await page.fill('[data-testid="email-input"]', 'admin@example.com')
await page.fill('[data-testid="password-input"]', 'password123')
await page.click('[data-testid="login-button"]')
// 等待重導向到儀表板
await page.waitForURL('/dashboard')
// 導航到使用者管理頁面
await page.click('[data-testid="nav-users"]')
await page.waitForURL('/users')
})
test('應該顯示使用者列表', async ({ page }) => {
// 等待資料載入
await page.waitForSelector('[data-testid="user-list"]')
// 檢查是否有使用者資料
const userRows = page.locator('[data-testid="user-row"]')
await expect(userRows).toHaveCountGreaterThan(0)
// 檢查表格標題
await expect(page.locator('text=名字')).toBeVisible()
await expect(page.locator('text=電子郵件')).toBeVisible()
await expect(page.locator('text=角色')).toBeVisible()
})
test('應該能夠搜尋使用者', async ({ page }) => {
// 等待頁面載入
await page.waitForSelector('[data-testid="search-input"]')
// 輸入搜尋關鍵字
await page.fill('[data-testid="search-input"]', 'john')
// 等待搜尋結果
await page.waitForTimeout(500)
// 檢查過濾結果
const userRows = page.locator('[data-testid="user-row"]')
await expect(userRows).toHaveCountGreaterThan(0)
// 檢查搜尋結果是否包含關鍵字
await expect(page.locator('text=john').first()).toBeVisible()
})
test('應該能夠新增使用者', async ({ page }) => {
// 點擊新增按鈕
await page.click('[data-testid="add-user-button"]')
// 等待對話框出現
await page.waitForSelector('[data-testid="user-dialog"]')
// 填寫表單
await page.fill('[data-testid="firstName-input"]', 'Test')
await page.fill('[data-testid="lastName-input"]', 'User')
await page.fill('[data-testid="email-input"]', 'test@example.com')
// 選擇角色
await page.click('[data-testid="role-dropdown"]')
await page.click('text=使用者')
// 選擇部門
await page.click('[data-testid="department-dropdown"]')
await page.click('text=工程部')
// 提交表單
await page.click('[data-testid="save-button"]')
// 等待成功訊息
await expect(page.locator('.p-toast-message-success')).toBeVisible()
// 檢查新使用者是否出現在列表中
await expect(page.locator('text=test@example.com')).toBeVisible()
})
test('應該能夠編輯使用者', async ({ page }) => {
// 點擊第一個使用者的編輯按鈕
await page.click('[data-testid="user-row"]:first-child [data-testid="edit-button"]')
// 等待對話框出現
await page.waitForSelector('[data-testid="user-dialog"]')
// 修改名字
await page.fill('[data-testid="firstName-input"]', 'Updated')
// 提交表單
await page.click('[data-testid="save-button"]')
// 等待成功訊息
await expect(page.locator('.p-toast-message-success')).toBeVisible()
// 檢查更新是否生效
await expect(page.locator('text=Updated').first()).toBeVisible()
})
test('應該能夠刪除使用者', async ({ page }) => {
// 記錄刪除前的使用者數量
const initialCount = await page.locator('[data-testid="user-row"]').count()
// 點擊第一個使用者的刪除按鈕
await page.click('[data-testid="user-row"]:first-child [data-testid="delete-button"]')
// 等待確認對話框
await page.waitForSelector('[data-testid="confirm-dialog"]')
// 確認刪除
await page.click('[data-testid="confirm-yes"]')
// 等待成功訊息
await expect(page.locator('.p-toast-message-success')).toBeVisible()
// 檢查使用者數量是否減少
const finalCount = await page.locator('[data-testid="user-row"]').count()
expect(finalCount).toBe(initialCount - 1)
})
test('應該支援批次操作', async ({ page }) => {
// 選擇多個使用者
await page.check('[data-testid="user-row"]:nth-child(1) [data-testid="select-checkbox"]')
await page.check('[data-testid="user-row"]:nth-child(2) [data-testid="select-checkbox"]')
// 檢查批次操作按鈕是否出現
await expect(page.locator('[data-testid="batch-actions"]')).toBeVisible()
// 執行批次刪除
await page.click('[data-testid="batch-delete-button"]')
// 確認批次刪除
await page.waitForSelector('[data-testid="confirm-dialog"]')
await page.click('[data-testid="confirm-yes"]')
// 等待成功訊息
await expect(page.locator('.p-toast-message-success')).toBeVisible()
})
test('應該支援分頁', async ({ page }) => {
// 檢查分頁元件是否存在
await expect(page.locator('.p-paginator')).toBeVisible()
// 點擊下一頁
await page.click('.p-paginator-next')
// 檢查 URL 是否更新
await expect(page).toHaveURL(/page=2/)
// 檢查是否載入了不同的資料
await page.waitForLoadState('networkidle')
})
test('應該支援排序', async ({ page }) => {
// 點擊名字欄位標題進行排序
await page.click('[data-testid="firstName-header"]')
// 等待排序完成
await page.waitForLoadState('networkidle')
// 檢查排序指示器
await expect(page.locator('[data-testid="firstName-header"] .p-sortable-column-icon')).toBeVisible()
})
})
4.5 第四章總結
在第四章中,我們深入探討了 PrimeVue 應用程式的進階優化技術:
🚀 效能優化重點
Vue 3 最佳化特性
- 組合式 API 的效能優勢
- 響應式系統優化
- 計算屬性和監聽器的正確使用
PrimeVue 元件優化
- DataTable 虛擬滾動
- 動態元件載入
- 事件委派和記憶體管理
記憶體管理
- 適當的事件監聽器清理
- Intersection Observer 的使用
- 組合式函數的生命週期管理
🌍 國際化實作
Vue I18n 整合
- 多語言系統架構
- 動態語言切換
- PrimeVue 元件本地化
最佳實務
- 語言包結構設計
- 格式化函數的使用
- 瀏覽器語言檢測
🎨 主題系統
設計令牌系統
- CSS 變數的系統化使用
- 語意化顏色命名
- 響應式設計考量
動態主題切換
- 主題設定持久化
- 暗黑模式支援
- 自訂主題建立
🧪 測試策略
測試金字塔
- 單元測試 (Unit Tests)
- 整合測試 (Integration Tests)
- 端對端測試 (E2E Tests)
測試工具整合
- Vitest 單元測試框架
- Playwright E2E 測試
- Vue Test Utils 元件測試
準備好進入第五章「實務案例與最佳實務」了嗎?
第五章:實務案例與最佳實務
5.1 企業級專案架構
在真實的企業環境中,PrimeVue 應用程式需要考慮更多的架構層面,包括模組化、可擴展性、維護性等。
5.1.1 專案結構設計
推薦的目錄結構
src/
├── assets/ # 靜態資源
│ ├── images/
│ ├── icons/
│ └── styles/
│ ├── globals.css
│ ├── variables.css
│ └── components/
├── components/ # 可重用元件
│ ├── common/ # 通用元件
│ │ ├── BaseButton.vue
│ │ ├── BaseInput.vue
│ │ └── BaseModal.vue
│ ├── layout/ # 版面元件
│ │ ├── AppHeader.vue
│ │ ├── AppSidebar.vue
│ │ └── AppFooter.vue
│ └── business/ # 業務元件
│ ├── UserComponents/
│ ├── ProductComponents/
│ └── OrderComponents/
├── composables/ # 組合式函數
│ ├── useApi.js
│ ├── useAuth.js
│ ├── useLocalStorage.js
│ └── useValidation.js
├── layouts/ # 頁面版面
│ ├── DefaultLayout.vue
│ ├── AuthLayout.vue
│ └── AdminLayout.vue
├── pages/ # 頁面元件
│ ├── auth/
│ ├── dashboard/
│ ├── users/
│ └── settings/
├── plugins/ # 外掛程式
│ ├── primevue.js
│ ├── i18n.js
│ └── router.js
├── services/ # API 服務
│ ├── api/
│ │ ├── userApi.js
│ │ ├── productApi.js
│ │ └── orderApi.js
│ ├── auth/
│ └── storage/
├── stores/ # 狀態管理
│ ├── modules/
│ │ ├── auth.js
│ │ ├── user.js
│ │ └── app.js
│ └── index.js
├── types/ # TypeScript 類型定義
│ ├── api.ts
│ ├── user.ts
│ └── common.ts
├── utils/ # 工具函數
│ ├── constants.js
│ ├── helpers.js
│ ├── validators.js
│ └── formatters.js
└── main.js
接下來讓我們看看模組化設計和狀態管理的最佳實務…
5.1.2 設計模式實作
工廠模式應用
在企業級應用中,我們經常需要根據不同條件創建不同的元件或服務實例。
// 元件工廠模式
export class ComponentFactory {
static createFormComponent(type, props) {
const components = {
'user': () => import('@/components/forms/UserForm.vue'),
'product': () => import('@/components/forms/ProductForm.vue'),
'order': () => import('@/components/forms/OrderForm.vue')
}
return components[type] ? components[type]() : null
}
static createTableComponent(dataType, options) {
const baseConfig = {
paginator: true,
rows: 20,
responsiveLayout: 'scroll',
...options
}
const typeConfigs = {
'user': {
selectionMode: 'multiple',
globalFilterFields: ['firstName', 'lastName', 'email']
},
'product': {
selectionMode: 'single',
globalFilterFields: ['name', 'category', 'sku']
}
}
return {
...baseConfig,
...typeConfigs[dataType]
}
}
}
服務定位器模式
// 服務註冊和定位
export class ServiceLocator {
static services = new Map()
static register(name, service) {
this.services.set(name, service)
}
static get(name) {
if (!this.services.has(name)) {
throw new Error(`Service ${name} not found`)
}
return this.services.get(name)
}
static has(name) {
return this.services.has(name)
}
}
// 使用範例
ServiceLocator.register('userApi', new UserApiService())
ServiceLocator.register('authService', new AuthService())
ServiceLocator.register('notificationService', new NotificationService())
5.2 效能監控與分析
5.2.1 效能指標追蹤
// 效能監控組合式函數
export function usePerformanceMonitor() {
const metrics = ref({
pageLoad: null,
firstContentfulPaint: null,
largestContentfulPaint: null,
cumulativeLayoutShift: null,
firstInputDelay: null
})
const measurePageLoad = () => {
if ('performance' in window) {
const navigation = performance.getEntriesByType('navigation')[0]
metrics.value.pageLoad = navigation.loadEventEnd - navigation.loadEventStart
}
}
const measureWebVitals = () => {
// 使用 web-vitals 庫測量核心指標
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS((metric) => { metrics.value.cumulativeLayoutShift = metric.value })
getFID((metric) => { metrics.value.firstInputDelay = metric.value })
getFCP((metric) => { metrics.value.firstContentfulPaint = metric.value })
getLCP((metric) => { metrics.value.largestContentfulPaint = metric.value })
})
}
onMounted(() => {
measurePageLoad()
measureWebVitals()
})
return { metrics }
}
5.3 部署與維護
5.3.1 生產環境優化
建置優化設定
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue', 'vue-router', 'pinia'],
'primevue': ['primevue/config', 'primevue/button', 'primevue/datatable'],
'utils': ['date-fns', 'lodash-es']
}
}
},
chunkSizeWarningLimit: 1000
},
optimizeDeps: {
include: ['primevue/button', 'primevue/datatable']
}
})
5.4 第五章總結
第五章重點涵蓋了企業級應用的實務考量:
🏗️ 架構設計
- 專案結構:模組化、可維護的目錄結構
- 設計模式:工廠模式、服務定位器等企業級模式
- 組件系統:可重用、可擴展的元件庫
📊 效能監控
- 指標追蹤:Core Web Vitals 監控
- 效能優化:代碼分割、懶載入
- 監控工具:整合專業監控服務
🚀 部署優化
- 建置配置:生產環境優化
- CDN 策略:靜態資源分發
- 快取策略:瀏覽器和服務器快取
第六章:疑難排解與調試
6.1 常見問題解決
6.1.1 PrimeVue 常見錯誤
元件載入問題
// 問題:元件未正確註冊
// 解決方案:確保正確匯入和註冊
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
const app = createApp(App)
app.use(PrimeVue)
// 全域註冊
app.component('Button', Button)
app.component('DataTable', DataTable)
// 或在元件中局部註冊
export default {
components: {
Button,
DataTable
}
}
樣式衝突處理
/* 問題:樣式被覆蓋 */
/* 解決方案:使用 CSS 層級或 scoped */
/* 方法一:提高特異性 */
.my-component .p-button {
background-color: #custom-color !important;
}
/* 方法二:使用 CSS 層級(推薦) */
@layer base, primevue, custom;
@layer custom {
.p-button {
background-color: #custom-color;
}
}
6.2 調試技巧
6.2.1 Vue DevTools 使用
// 開發環境調試輔助
export function useDebugInfo() {
const debugMode = ref(process.env.NODE_ENV === 'development')
const log = (message, data) => {
if (debugMode.value) {
console.log(`[DEBUG] ${message}`, data)
}
}
const logPerformance = (label, fn) => {
if (debugMode.value) {
console.time(label)
const result = fn()
console.timeEnd(label)
return result
}
return fn()
}
return { log, logPerformance }
}
第七章:認證準備與進階學習
7.1 認證考試準備
7.1.1 Vue.js 認證重點
核心概念複習
- 響應式系統:ref、reactive、computed、watch
- 組合式 API:setup、lifecycle hooks、composables
- 元件系統:props、emits、slots、provide/inject
- 路由系統:動態路由、導航守衛、懶載入
- 狀態管理:Pinia stores、actions、getters
7.1.2 PrimeVue 專業技能
必備技能清單
- ✅ 掌握 20+ 核心元件的使用
- ✅ 理解主題系統和自訂樣式
- ✅ 實作完整的 CRUD 應用程式
- ✅ 整合國際化和無障礙功能
- ✅ 效能優化和最佳實務
- ✅ 測試策略和品質保證
7.2 持續學習資源
7.2.1 官方資源
7.2.2 社群資源
- GitHub 開源專案
- Stack Overflow 問答
- Vue.js 台灣社群
- 技術部落格和教學影片
附錄
A. 元件速查表
A.1 常用元件屬性
Button 元件
<Button
label="按鈕文字"
icon="pi pi-check"
:loading="false"
:disabled="false"
class="p-button-success"
@click="handleClick"
/>
DataTable 元件
<DataTable
:value="data"
:paginator="true"
:rows="10"
:loading="loading"
dataKey="id"
v-model:selection="selectedItems"
selectionMode="multiple"
>
<Column field="name" header="名稱" sortable />
</DataTable>
B. 最佳實務檢查清單
B.1 開發階段
- 使用 TypeScript 進行類型檢查
- 遵循 Vue.js 風格指南
- 建立可重用的組合式函數
- 實作適當的錯誤處理
- 撰寫單元測試和整合測試
B.2 效能優化
- 使用虛擬滾動處理大量資料
- 實作懶載入和代碼分割
- 優化圖片和靜態資源
- 監控和分析效能指標
- 使用適當的快取策略
B.3 使用者體驗
- 實作響應式設計
- 支援無障礙功能
- 提供載入狀態指示
- 實作友善的錯誤訊息
- 支援離線功能
B.4 安全性
- 實作 HTTPS
- 驗證使用者輸入
- 使用 CSP (Content Security Policy)
- 定期更新依賴套件
- 實作適當的認證和授權
結語
🎉 恭喜您完成 PrimeVue 使用教學!
通過本教學手冊,您已經:
✅ 掌握了 PrimeVue 的核心概念和元件使用 ✅ 學會了建立完整的 Vue.js 應用程式 ✅ 理解了企業級專案的架構設計 ✅ 掌握了效能優化和測試策略 ✅ 具備了解決實際業務問題的能力
下一步建議
- 實戰練習:建立自己的專案來鞏固所學知識
- 社群參與:加入 Vue.js 和 PrimeVue 社群,分享經驗
- 持續學習:關注新版本更新和最佳實務
- 認證準備:考慮參加相關的技術認證考試
- 知識分享:將學習心得分享給團隊和社群
學習成果評估
您現在應該能夠:
- 🎯 獨立建立和維護 PrimeVue 應用程式
- 🎯 解決複雜的業務需求和技術挑戰
- 🎯 指導團隊成員進行 Vue.js 開發
- 🎯 設計可擴展和可維護的前端架構
- 🎯 實作企業級的品質標準和最佳實務
感謝您的學習,祝您在 PrimeVue 和 Vue.js 的開發路上越走越順利!
教學手冊版本:v1.0
最後更新:2024年
作者:GitHub Copilot
授權:MIT License