跳至主要内容

建立第一個 Applet

本指南將帶你建立一個簡單的 Applet,展示如何使用 AppFuse Web 快速開發業務功能模組。

什麼是 Applet?

Applet 是獨立的業務功能模組,具有以下特性:

  • 獨立路由:擁有自己的 URL 路徑
  • 完整功能:包含 UI、狀態、業務邏輯
  • 可插拔:可獨立開發、測試、部署
  • 標準佈局:使用 <AppletShell> 提供一致的外觀

範例:任務管理 Applet

我們將建立一個簡單的任務管理 Applet,包含:

  • 任務列表顯示
  • 新增任務表單
  • 任務狀態切換

步驟 1:定義類型

src/types/ 建立 task.types.ts

export interface Task {
id: string;
title: string;
description?: string;
completed: boolean;
createdAt: string;
updatedAt?: string;
}

export interface TaskCreateRequest {
title: string;
description?: string;
}

export interface TaskUpdateRequest {
title?: string;
description?: string;
}

步驟 2:建立 API 服務

src/services/ 建立 task.service.ts

import { api } from './api';
import type { Task, TaskCreateRequest, TaskUpdateRequest } from '@/types/task.types';

export const taskService = {
findAll: async (completed?: boolean) => {
const params = completed !== undefined ? { completed } : {};
return api.get<Task[]>('/api/tasks', { params });
},

findById: async (id: string) => {
return api.get<Task>(`/api/tasks/${id}`);
},

create: async (data: TaskCreateRequest) => {
return api.post<Task>('/api/tasks', data);
},

update: async (id: string, data: TaskUpdateRequest) => {
return api.put<Task>(`/api/tasks/${id}`, data);
},

delete: async (id: string) => {
return api.delete(`/api/tasks/${id}`);
},

markAsCompleted: async (id: string) => {
return api.patch<Task>(`/api/tasks/${id}/complete`);
},
};

步驟 3:建立 Mock Handlers(開發階段)

src/mocks/handlers/ 建立 task.handlers.ts

import { http, HttpResponse } from 'msw';
import type { Task } from '@/types/task.types';

// Mock 資料
let tasks: Task[] = [
{
id: '1',
title: 'Learn AppFuse Web',
description: 'Complete the getting started guide',
completed: false,
createdAt: new Date().toISOString(),
},
{
id: '2',
title: 'Build first Applet',
description: 'Create a task management Applet',
completed: true,
createdAt: new Date().toISOString(),
},
];

export const taskHandlers = [
// GET /api/tasks
http.get('/api/tasks', ({ request }) => {
const url = new URL(request.url);
const completed = url.searchParams.get('completed');

let filtered = tasks;
if (completed !== null) {
filtered = tasks.filter(
(t) => t.completed === (completed === 'true')
);
}

return HttpResponse.json(filtered);
}),

// POST /api/tasks
http.post('/api/tasks', async ({ request }) => {
const data = await request.json() as { title: string; description?: string };
const newTask: Task = {
id: String(tasks.length + 1),
title: data.title,
description: data.description,
completed: false,
createdAt: new Date().toISOString(),
};
tasks.push(newTask);
return HttpResponse.json(newTask, { status: 201 });
}),

// PATCH /api/tasks/:id/complete
http.patch('/api/tasks/:id/complete', ({ params }) => {
const task = tasks.find((t) => t.id === params.id);
if (!task) {
return new HttpResponse(null, { status: 404 });
}
task.completed = true;
task.updatedAt = new Date().toISOString();
return HttpResponse.json(task);
}),

// DELETE /api/tasks/:id
http.delete('/api/tasks/:id', ({ params }) => {
tasks = tasks.filter((t) => t.id !== params.id);
return new HttpResponse(null, { status: 204 });
}),
];

src/mocks/handlers/index.ts 中註冊:

import { authHandlers } from './auth.handlers';
import { taskHandlers } from './task.handlers';

export const handlers = [
...authHandlers,
...taskHandlers,
];

步驟 4:建立 Applet 組件

src/applets/tasks/ 建立 TaskListApplet.tsx

import { useState, useEffect } from 'react';
import { AppletShell, DataTable, Button, TextField, Dialog } from '@appfuse/appfuse-web';
import { taskService } from '@/services/task.service';
import type { Task } from '@/types/task.types';

export function TaskListApplet() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newTask, setNewTask] = useState({ title: '', description: '' });

// 載入任務列表
useEffect(() => {
loadTasks();
}, []);

const loadTasks = async () => {
setLoading(true);
try {
const data = await taskService.findAll();
setTasks(data);
} catch (error) {
console.error('Failed to load tasks:', error);
} finally {
setLoading(false);
}
};

// 建立新任務
const handleCreate = async () => {
try {
await taskService.create(newTask);
setShowCreateDialog(false);
setNewTask({ title: '', description: '' });
loadTasks();
} catch (error) {
console.error('Failed to create task:', error);
}
};

// 標記為完成
const handleComplete = async (id: string) => {
try {
await taskService.markAsCompleted(id);
loadTasks();
} catch (error) {
console.error('Failed to complete task:', error);
}
};

// 刪除任務
const handleDelete = async (id: string) => {
try {
await taskService.delete(id);
loadTasks();
} catch (error) {
console.error('Failed to delete task:', error);
}
};

return (
<AppletShell
title="任務管理"
description="管理你的任務清單"
actions={
<Button
variant="primary"
onClick={() => setShowCreateDialog(true)}
>
新增任務
</Button>
}
>
<DataTable
data={tasks}
loading={loading}
columns={[
{
key: 'title',
header: '標題',
},
{
key: 'description',
header: '描述',
},
{
key: 'completed',
header: '狀態',
render: (task) => (
<span className={task.completed ? 'text-green-600' : 'text-gray-400'}>
{task.completed ? '已完成' : '進行中'}
</span>
),
},
{
key: 'actions',
header: '操作',
render: (task) => (
<div className="flex gap-2">
{!task.completed && (
<Button
size="sm"
variant="secondary"
onClick={() => handleComplete(task.id)}
>
完成
</Button>
)}
<Button
size="sm"
variant="danger"
onClick={() => handleDelete(task.id)}
>
刪除
</Button>
</div>
),
},
]}
/>

{/* 新增任務對話框 */}
<Dialog
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
title="新增任務"
>
<div className="space-y-4">
<TextField
label="標題"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
required
/>
<TextField
label="描述"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
multiline
rows={3}
/>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => setShowCreateDialog(false)}
>
取消
</Button>
<Button
variant="primary"
onClick={handleCreate}
disabled={!newTask.title}
>
建立
</Button>
</div>
</div>
</Dialog>
</AppletShell>
);
}

步驟 5:註冊路由

src/routes/AppRoutes.tsx 中添加路由:

import { Routes, Route } from 'react-router-dom';
import { TaskListApplet } from '@/applets/tasks/TaskListApplet';

export function AppRoutes() {
return (
<Routes>
{/* 其他路由 */}
<Route path="/tasks" element={<TaskListApplet />} />
</Routes>
);
}

步驟 6:註冊到 Application Launcher

在應用程式啟動器中註冊 Applet,讓使用者可以從主選單存取:

// src/config/applets.config.ts
export const appletRegistry = [
{
id: 'tasks',
name: '任務管理',
icon: 'CheckSquare',
path: '/tasks',
description: '管理你的任務清單',
},
// 其他 Applets...
];

步驟 7:測試 Applet

啟動開發伺服器:

npm run dev

訪問 http://localhost:5173/tasks 查看你的 Applet。

進階功能

使用 React Hook Form 處理表單

import { useForm } from 'react-hook-form';
import { Input } from '@appfuse/appfuse-web/form';
import { schema, validatorResolver } from '@appfuse/appfuse-web/form/validator';

const taskSchema = schema.object({
title: schema.string().required(),
description: schema.string(),
});

function TaskForm() {
const { control, handleSubmit } = useForm({
resolver: validatorResolver(taskSchema),
defaultValues: {
title: '',
description: '',
},
});

const onSubmit = async (data) => {
await taskService.create(data);
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input name="title" control={control} label="Title" />
<Input name="description" control={control} label="Description" />
<button type="submit" className="btn btn-primary">Save</button>
</form>
);
}

使用 RTK Query 管理伺服器狀態

參考 狀態管理指南 了解如何使用 RTK Query。

專案結構總覽

完成後的專案結構:

src/
├── applets/tasks/
│ └── TaskListApplet.tsx
├── services/
│ └── task.service.ts
├── mocks/handlers/
│ └── task.handlers.ts
├── types/
│ └── task.types.ts
└── routes/
└── AppRoutes.tsx

下一步

參考資源