在前四天的建置中,我們用 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)我們將: