iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 5

Day5:Mantine UI 與模板管理介面

  • 分享至 

  • xImage
  •  

從簡單組件到完整管理介面

在前四天的建置中,我們用 Zustand 建立了基本的狀態管理,並實作了 OTP 發送與驗證功能。今天我們要進一步提升使用者體驗,使用 Mantine UI 框架並建立模板管理系統

從過往的接案經驗來看,客戶對於「可以客製化內容」這個功能都希望擁有。這不只是技術功能,更是讓使用者感受到產品彈性的體驗。

為什麼選擇 Mantine UI?

在前端 UI 框架的選擇上,我嘗試過各種解決方案:

主流 React UI 框架比較

UI 框架 組件豐富度 客製化難度 TypeScript 支援 包大小 學習曲線 適用場景
Ant Design 極高 困難 良好 中等 企業後台
Material-UI 中等 優秀 陡峭 品牌要求高
Chakra UI 容易 優秀 平緩 快速原型
Mantine 極高 容易 優秀 平緩 管理介面 ✅

選擇 Mantine 的關鍵理由

  • 組件完整性:包含 100+ 預建組件,覆蓋所有常見需求
  • 開發體驗:優秀的 TypeScript 支援和 API 設計
  • 主題系統:內建深色模式,樣式客製化簡單
  • 效能優異:按需載入,不影響打包大小
  • 文件品質:官方文件詳細且包含實際範例

現有專案狀況分析

讓我們先檢視目前 kyo-dashboard 的現況:

// apps/kyo-dashboard/src/stores/otpStore.ts (Day4 實作)
export const useOtpStore = create<OtpStore>((set, get) => ({
  // 基本的 OTP 發送和驗證狀態管理
  sendOtpAction: async (request) => { /* ... */ },
  verifyOtpAction: async (request) => { /* ... */ },
  // 需要新增:模板管理功能
}));

目前缺少的功能:

  • 模板 CRUD 操作
  • 表格顯示與分頁
  • 表單驗證與錯誤處理
  • 用戶友善的載入狀態

整合 Mantine 到現有專案

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>
  );
}

擴展 Zustand Store 支援模板管理

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>
  );
}

整合模板選擇到 OTP 發送

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
響應式設計:桌面與行動版的切換
使用者體驗:載入狀態、錯誤處理、通知系統
路由系統:多頁面應用程式架構

UI/UX 設計哲學

以使用者為中心的設計

  1. 直觀性:功能分類清楚,操作流程自然
  2. 回饋機制:每個操作都有明確的成功/失敗反饋
  3. 錯誤預防:表單驗證避免使用者輸入錯誤
  4. 效率優化:常用功能放在顯眼位置

技術與設計的平衡

  • Mantine 提供一致的設計語言
  • 自訂驗證邏輯確保資料正確性
  • 響應式設計適應不同裝置
  • 無障礙功能支援更多使用者

下一步規劃

明天(Day6)我們將:

  1. 建立數據分析頁面(圖表與統計)
  2. 加入實時通知與 WebSocket
  3. 實作使用者偏好設定
  4. 優化效能與快取策略

上一篇
Day4:Zustand 狀態管理與型別安全的 API 層
下一篇
Day 6: 型別安全的 API 整合與前端部署實戰
系列文
30 天製作工作室 SaaS 產品 (前端篇)6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言