在前四天的建置中,我們用 Zustand 建立了基本的狀態管理,並實作了 OTP 發送與驗證功能。今天我們要進一步提升使用者體驗,使用 Mantine UI 框架並建立模板管理系統。
從過往的接案經驗來看,客戶對於「可以客製化內容」這個功能都希望擁有。這不只是技術功能,更是讓使用者感受到產品彈性的體驗。
在前端 UI 框架的選擇上,我嘗試過各種解決方案:
主流 React UI 框架比較:
UI 框架 | 組件豐富度 | 客製化難度 | TypeScript 支援 | 包大小 | 學習曲線 | 適用場景 |
---|---|---|---|---|---|---|
Ant Design | 極高 | 困難 | 良好 | 大 | 中等 | 企業後台 |
Material-UI | 高 | 中等 | 優秀 | 大 | 陡峭 | 品牌要求高 |
Chakra UI | 高 | 容易 | 優秀 | 中 | 平緩 | 快速原型 |
Mantine | 極高 | 容易 | 優秀 | 中 | 平緩 | 管理介面 ✅ |
選擇 Mantine 的關鍵理由:
讓我們先檢視目前 kyo-dashboard
的現況:
// apps/kyo-dashboard/src/stores/otpStore.ts (Day4 實作)
export const useOtpStore = create<OtpStore>((set, get) => ({
// 基本的 OTP 發送和驗證狀態管理
sendOtpAction: async (request) => { /* ... */ },
verifyOtpAction: async (request) => { /* ... */ },
// 需要新增:模板管理功能
}));
目前缺少的功能:
1. 安裝 Mantine 依賴
cd apps/kyo-dashboard
pnpm add @mantine/core @mantine/hooks @mantine/form @mantine/notifications @mantine/modals
pnpm add @tabler/icons-react
2. 設定 Mantine Provider
// apps/kyo-dashboard/src/App.tsx
import { MantineProvider, createTheme } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
const theme = createTheme({
/** 自訂主題設定 */
primaryColor: 'blue',
defaultRadius: 'sm',
fontFamily: 'Inter, system-ui, sans-serif',
headings: {
fontFamily: 'Inter, system-ui, sans-serif',
},
});
function App() {
return (
<MantineProvider theme={theme}>
<ModalsProvider>
<Notifications position="top-right" />
{/* 其他應用程式內容 */}
</ModalsProvider>
</MantineProvider>
);
}
3. 建立 Layout 組件
// apps/kyo-dashboard/src/components/Layout.tsx
import { AppShell, Burger, Group, Text, NavLink } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconMessages, IconTemplate, IconAnalytics } from '@tabler/icons-react';
interface LayoutProps {
children: React.ReactNode;
}
export function Layout({ children }: LayoutProps) {
const [opened, { toggle }] = useDisclosure();
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 250, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md">
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Text size="lg" fw={600}>Kyo Dashboard</Text>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<NavLink
href="/otp"
label="OTP 測試"
leftSection={<IconMessages size="1rem" />}
/>
<NavLink
href="/templates"
label="簡訊模板"
leftSection={<IconTemplate size="1rem" />}
/>
<NavLink
href="/analytics"
label="數據分析"
leftSection={<IconAnalytics size="1rem" />}
/>
</AppShell.Navbar>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}
4. 更新型別定義
// apps/kyo-dashboard/src/types/api.ts (擴展現有型別)
export interface Template {
id: number;
name: string;
content: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateTemplateRequest {
name: string;
content: string;
isActive?: boolean;
}
export interface UpdateTemplateRequest {
id: number;
name?: string;
content?: string;
isActive?: boolean;
}
5. 新增模板 API 函數
// apps/kyo-dashboard/src/lib/api.ts (擴展現有 API)
export async function getTemplates(baseUrl: string): Promise<Template[]> {
const res = await fetch(`${baseUrl}/api/templates`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
return handle<Template[]>(res);
}
export async function createTemplate(
baseUrl: string,
body: CreateTemplateRequest
): Promise<Template> {
const res = await fetch(`${baseUrl}/api/templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return handle<Template>(res);
}
export async function updateTemplate(
baseUrl: string,
id: number,
body: Partial<UpdateTemplateRequest>
): Promise<Template> {
const res = await fetch(`${baseUrl}/api/templates/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return handle<Template>(res);
}
export async function deleteTemplate(baseUrl: string, id: number): Promise<void> {
const res = await fetch(`${baseUrl}/api/templates/${id}`, {
method: 'DELETE'
});
await handle<void>(res);
}
6. 建立模板專用 Store
// apps/kyo-dashboard/src/stores/templateStore.ts
import { create } from 'zustand';
import {
getTemplates,
createTemplate,
updateTemplate,
deleteTemplate
} from '../lib/api';
import type { Template, CreateTemplateRequest, UpdateTemplateRequest } from '../types/api';
import { notifications } from '@mantine/notifications';
interface TemplateState {
// 資料狀態
templates: Template[];
loading: boolean;
error: string | null;
// 表單狀態
isModalOpen: boolean;
editingTemplate: Template | null;
}
interface TemplateActions {
// 資料操作
fetchTemplates: () => Promise<void>;
createTemplateAction: (data: CreateTemplateRequest) => Promise<boolean>;
updateTemplateAction: (id: number, data: Partial<UpdateTemplateRequest>) => Promise<boolean>;
deleteTemplateAction: (id: number) => Promise<boolean>;
// UI 操作
openCreateModal: () => void;
openEditModal: (template: Template) => void;
closeModal: () => void;
clearError: () => void;
}
type TemplateStore = TemplateState & TemplateActions;
export const useTemplateStore = create<TemplateStore>((set, get) => ({
// 初始狀態
templates: [],
loading: false,
error: null,
isModalOpen: false,
editingTemplate: null,
// 取得模板列表
fetchTemplates: async () => {
set({ loading: true, error: null });
try {
const baseUrl = 'http://localhost:3000';
const templates = await getTemplates(baseUrl);
set({ templates, loading: false });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '取得模板失敗';
set({ error: errorMessage, loading: false });
notifications.show({
title: '錯誤',
message: errorMessage,
color: 'red'
});
}
},
// 建立模板
createTemplateAction: async (data) => {
set({ loading: true, error: null });
try {
const baseUrl = 'http://localhost:3000';
const newTemplate = await createTemplate(baseUrl, data);
set(state => ({
templates: [...state.templates, newTemplate],
loading: false,
isModalOpen: false
}));
notifications.show({
title: '成功',
message: '模板建立成功',
color: 'green'
});
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '建立模板失敗';
set({ error: errorMessage, loading: false });
notifications.show({
title: '錯誤',
message: errorMessage,
color: 'red'
});
return false;
}
},
// 更新模板
updateTemplateAction: async (id, data) => {
set({ loading: true, error: null });
try {
const baseUrl = 'http://localhost:3000';
const updatedTemplate = await updateTemplate(baseUrl, id, data);
set(state => ({
templates: state.templates.map(t => t.id === id ? updatedTemplate : t),
loading: false,
isModalOpen: false,
editingTemplate: null
}));
notifications.show({
title: '成功',
message: '模板更新成功',
color: 'green'
});
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '更新模板失敗';
set({ error: errorMessage, loading: false });
notifications.show({
title: '錯誤',
message: errorMessage,
color: 'red'
});
return false;
}
},
// 刪除模板
deleteTemplateAction: async (id) => {
try {
const baseUrl = 'http://localhost:3000';
await deleteTemplate(baseUrl, id);
set(state => ({
templates: state.templates.filter(t => t.id !== id)
}));
notifications.show({
title: '成功',
message: '模板刪除成功',
color: 'green'
});
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '刪除模板失敗';
notifications.show({
title: '錯誤',
message: errorMessage,
color: 'red'
});
return false;
}
},
// UI 操作
openCreateModal: () => set({ isModalOpen: true, editingTemplate: null }),
openEditModal: (template) => set({ isModalOpen: true, editingTemplate: template }),
closeModal: () => set({ isModalOpen: false, editingTemplate: null }),
clearError: () => set({ error: null })
}));
7. 模板表單組件
// apps/kyo-dashboard/src/components/TemplateForm.tsx
import { useEffect } from 'react';
import { Modal, TextInput, Textarea, Button, Group, Switch } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTemplateStore } from '../stores/templateStore';
import type { CreateTemplateRequest } from '../types/api';
export function TemplateForm() {
const {
isModalOpen,
editingTemplate,
loading,
closeModal,
createTemplateAction,
updateTemplateAction
} = useTemplateStore();
const form = useForm<CreateTemplateRequest>({
initialValues: {
name: '',
content: '',
isActive: true
},
validate: {
name: (value) => {
if (!value.trim()) return '模板名稱不能為空';
if (value.length < 2) return '模板名稱至少 2 個字符';
if (value.length > 50) return '模板名稱不能超過 50 個字符';
return null;
},
content: (value) => {
if (!value.trim()) return '模板內容不能為空';
if (!/{code}/.test(value)) return '模板內容必須包含 {code} 變數';
if (value.length > 500) return '模板內容不能超過 500 個字符';
return null;
}
}
});
// 編輯模式時填入現有資料
useEffect(() => {
if (editingTemplate) {
form.setValues({
name: editingTemplate.name,
content: editingTemplate.content,
isActive: editingTemplate.isActive
});
} else {
form.reset();
}
}, [editingTemplate]);
const handleSubmit = async (values: CreateTemplateRequest) => {
let success = false;
if (editingTemplate) {
success = await updateTemplateAction(editingTemplate.id, values);
} else {
success = await createTemplateAction(values);
}
if (success) {
form.reset();
}
};
const handleClose = () => {
closeModal();
form.reset();
};
return (
<Modal
opened={isModalOpen}
onClose={handleClose}
title={editingTemplate ? '編輯模板' : '建立模板'}
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
label="模板名稱"
placeholder="輸入模板名稱"
required
{...form.getInputProps('name')}
mb="md"
/>
<Textarea
label="模板內容"
placeholder="您的驗證碼:{code},請於 5 分鐘內輸入。"
description="必須包含 {code} 變數,會自動替換為實際驗證碼"
required
rows={4}
{...form.getInputProps('content')}
mb="md"
/>
<Switch
label="啟用此模板"
description="停用的模板不會出現在發送選項中"
{...form.getInputProps('isActive', { type: 'checkbox' })}
mb="lg"
/>
<Group justify="flex-end">
<Button variant="subtle" onClick={handleClose}>
取消
</Button>
<Button type="submit" loading={loading}>
{editingTemplate ? '更新' : '建立'}
</Button>
</Group>
</form>
</Modal>
);
}
8. 模板列表與表格
// apps/kyo-dashboard/src/components/TemplateList.tsx
import { useEffect } from 'react';
import {
Table,
Button,
Group,
Badge,
ActionIcon,
Text,
Paper,
Stack,
LoadingOverlay,
Alert
} from '@mantine/core';
import {
IconPlus,
IconEdit,
IconTrash,
IconAlertCircle
} from '@tabler/icons-react';
import { modals } from '@mantine/modals';
import { useTemplateStore } from '../stores/templateStore';
import { TemplateForm } from './TemplateForm';
export function TemplateList() {
const {
templates,
loading,
error,
fetchTemplates,
openCreateModal,
openEditModal,
deleteTemplateAction,
clearError
} = useTemplateStore();
useEffect(() => {
fetchTemplates();
}, []);
const handleDelete = (id: number, name: string) => {
modals.openConfirmModal({
title: '確認刪除',
children: (
<Text size="sm">
確定要刪除模板「{name}」嗎?此操作無法恢復。
</Text>
),
labels: { confirm: '刪除', cancel: '取消' },
confirmProps: { color: 'red' },
onConfirm: () => deleteTemplateAction(id),
});
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-TW', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const rows = templates.map((template) => (
<Table.Tr key={template.id}>
<Table.Td>
<Text fw={500}>{template.name}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed" style={{ maxWidth: 300 }}>
{template.content.length > 50
? `${template.content.substring(0, 50)}...`
: template.content
}
</Text>
</Table.Td>
<Table.Td>
<Badge
color={template.isActive ? 'green' : 'gray'}
variant="light"
>
{template.isActive ? '啟用' : '停用'}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{formatDate(template.updatedAt)}
</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="subtle"
onClick={() => openEditModal(template)}
>
<IconEdit size="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDelete(template.id, template.name)}
>
<IconTrash size="1rem" />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
));
return (
<Stack>
<Group justify="space-between">
<div>
<Text size="xl" fw={600}>簡訊模板管理</Text>
<Text size="sm" c="dimmed">
管理 OTP 簡訊的自訂模板
</Text>
</div>
<Button
leftSection={<IconPlus size="1rem" />}
onClick={openCreateModal}
>
新增模板
</Button>
</Group>
{error && (
<Alert
icon={<IconAlertCircle size="1rem" />}
color="red"
onClose={clearError}
withCloseButton
>
{error}
</Alert>
)}
<Paper withBorder style={{ position: 'relative' }}>
<LoadingOverlay visible={loading} />
{templates.length === 0 && !loading ? (
<Stack align="center" p="xl">
<Text c="dimmed">還沒有任何模板</Text>
<Button
variant="light"
onClick={openCreateModal}
leftSection={<IconPlus size="1rem" />}
>
建立第一個模板
</Button>
</Stack>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>名稱</Table.Th>
<Table.Th>內容</Table.Th>
<Table.Th>狀態</Table.Th>
<Table.Th>更新時間</Table.Th>
<Table.Th>操作</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
)}
</Paper>
<TemplateForm />
</Stack>
);
}
9. 更新 OTP 發送表單
// apps/kyo-dashboard/src/components/OtpSendForm.tsx (更新現有組件)
import { useState, useEffect } from 'react';
import {
Paper,
TextInput,
Select,
Button,
Group,
Text,
Alert,
Stack
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useOtpStore } from '../stores/otpStore';
import { useTemplateStore } from '../stores/templateStore';
export function OtpSendForm() {
const { sendOtpAction, sendLoading, sendError } = useOtpStore();
const { templates, fetchTemplates } = useTemplateStore();
const form = useForm({
initialValues: {
phone: '',
templateId: null as number | null
},
validate: {
phone: (value) => {
if (!value.trim()) return '請輸入手機號碼';
if (!/^09\d{8}$/.test(value)) return '請輸入有效的台灣手機號碼';
return null;
}
}
});
useEffect(() => {
fetchTemplates();
}, []);
const handleSubmit = async (values: { phone: string; templateId: number | null }) => {
const result = await sendOtpAction({
phone: values.phone,
templateId: values.templateId || undefined
});
if (result.success) {
form.reset();
}
};
const templateOptions = templates
.filter(t => t.isActive)
.map(t => ({
value: t.id.toString(),
label: t.name
}));
return (
<Paper withBorder p="md">
<Stack>
<div>
<Text size="lg" fw={600}>發送 OTP 驗證碼</Text>
<Text size="sm" c="dimmed">
向指定手機號碼發送驗證碼
</Text>
</div>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="手機號碼"
placeholder="09XXXXXXXX"
required
{...form.getInputProps('phone')}
/>
<Select
label="簡訊模板"
placeholder="選擇模板(可選)"
description="不選擇將使用預設模板"
data={templateOptions}
value={form.values.templateId?.toString() || null}
onChange={(value) => form.setFieldValue('templateId', value ? parseInt(value) : null)}
clearable
/>
{sendError && (
<Alert color="red">
{sendError}
</Alert>
)}
<Group justify="flex-end">
<Button type="submit" loading={sendLoading}>
發送驗證碼
</Button>
</Group>
</Stack>
</form>
</Stack>
</Paper>
);
}
10. 設定 React Router
cd apps/kyo-dashboard
pnpm add react-router-dom
// apps/kyo-dashboard/src/pages/TemplatesPage.tsx
import { TemplateList } from '../components/TemplateList';
export function TemplatesPage() {
return <TemplateList />;
}
// apps/kyo-dashboard/src/pages/OtpPage.tsx
import { Stack } from '@mantine/core';
import { OtpSendForm } from '../components/OtpSendForm';
import { OtpVerifyForm } from '../components/OtpVerifyForm';
export function OtpPage() {
return (
<Stack>
<OtpSendForm />
<OtpVerifyForm />
</Stack>
);
}
// apps/kyo-dashboard/src/App.tsx (更新路由)
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Layout } from './components/Layout';
import { OtpPage } from './pages/OtpPage';
import { TemplatesPage } from './pages/TemplatesPage';
function App() {
return (
<MantineProvider theme={theme}>
<ModalsProvider>
<Notifications position="top-right" />
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<OtpPage />} />
<Route path="/otp" element={<OtpPage />} />
<Route path="/templates" element={<TemplatesPage />} />
</Routes>
</Layout>
</BrowserRouter>
</ModalsProvider>
</MantineProvider>
);
}
11. 進階表單驗證
// apps/kyo-dashboard/src/utils/validation.ts
export const templateValidation = {
name: (value: string) => {
if (!value?.trim()) return '模板名稱不能為空';
if (value.length < 2) return '模板名稱至少需要 2 個字符';
if (value.length > 50) return '模板名稱不能超過 50 個字符';
if (!/^[a-zA-Z0-9_\u4e00-\u9fa5\s-]+$/.test(value)) {
return '模板名稱只能包含字母、數字、中文、底線、破折號和空格';
}
return null;
},
content: (value: string) => {
if (!value?.trim()) return '模板內容不能為空';
if (value.length > 500) return '模板內容不能超過 500 個字符';
// 檢查必要變數
if (!/{code}/.test(value)) {
return '模板內容必須包含 {code} 變數';
}
// 檢查變數格式
const variables = value.match(/{[^}]+}/g) || [];
const allowedVars = ['{code}', '{phone}'];
const invalidVars = variables.filter(v => !allowedVars.includes(v));
if (invalidVars.length > 0) {
return `不支援的變數:${invalidVars.join(', ')}。支援的變數:${allowedVars.join(', ')}`;
}
return null;
}
};
12. 載入狀態與錯誤處理
// apps/kyo-dashboard/src/components/LoadingState.tsx
import { Center, Loader, Text, Stack } from '@mantine/core';
interface LoadingStateProps {
message?: string;
}
export function LoadingState({ message = '載入中...' }: LoadingStateProps) {
return (
<Center h={200}>
<Stack align="center" gap="md">
<Loader size="md" />
<Text size="sm" c="dimmed">{message}</Text>
</Stack>
</Center>
);
}
// apps/kyo-dashboard/src/components/ErrorState.tsx
import { Alert, Button, Stack, Text } from '@mantine/core';
import { IconAlertCircle, IconRefresh } from '@tabler/icons-react';
interface ErrorStateProps {
title?: string;
message: string;
onRetry?: () => void;
}
export function ErrorState({
title = '發生錯誤',
message,
onRetry
}: ErrorStateProps) {
return (
<Alert icon={<IconAlertCircle size="1rem" />} color="red">
<Stack gap="sm">
<div>
<Text fw={500}>{title}</Text>
<Text size="sm">{message}</Text>
</div>
{onRetry && (
<Button
variant="light"
size="xs"
leftSection={<IconRefresh size="0.8rem" />}
onClick={onRetry}
>
重試
</Button>
)}
</Stack>
</Alert>
);
}
13. 響應式表格
// apps/kyo-dashboard/src/components/ResponsiveTemplateList.tsx
import { useMediaQuery } from '@mantine/hooks';
import { Card, Group, Stack, Text, Badge, ActionIcon } from '@mantine/core';
export function ResponsiveTemplateList() {
const isMobile = useMediaQuery('(max-width: 768px)');
if (isMobile) {
// 行動版卡片顯示
return (
<Stack>
{templates.map((template) => (
<Card key={template.id} withBorder>
<Group justify="space-between" mb="xs">
<Text fw={500}>{template.name}</Text>
<Badge color={template.isActive ? 'green' : 'gray'}>
{template.isActive ? '啟用' : '停用'}
</Badge>
</Group>
<Text size="sm" c="dimmed" mb="sm">
{template.content}
</Text>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{formatDate(template.updatedAt)}
</Text>
<Group gap="xs">
<ActionIcon variant="subtle" onClick={() => openEditModal(template)}>
<IconEdit size="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleDelete(template.id, template.name)}
>
<IconTrash size="1rem" />
</ActionIcon>
</Group>
</Group>
</Card>
))}
</Stack>
);
}
// 桌面版表格顯示(現有組件)
return <TemplateList />;
}
✅ Mantine UI 整合:完整的 UI 框架配置與主題設定
✅ 模板管理系統:CRUD 操作的完整實作
✅ 進階表單驗證:支援變數檢查與錯誤處理
✅ 狀態管理擴展:專用的 TemplateStore
✅ 響應式設計:桌面與行動版的切換
✅ 使用者體驗:載入狀態、錯誤處理、通知系統
✅ 路由系統:多頁面應用程式架構
以使用者為中心的設計:
技術與設計的平衡:
明天(Day6)我們將: