建立第一個 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 { useCallback, useEffect, useState } from 'react'
import { ListTodo, Plus } from 'lucide-react'
import type { ColumnDef } from '@tanstack/react-table'
import {
Badge,
Button,
DataTable,
Input,
Dialog,
Textarea,
} from '@appfuse/appfuse-web/components'
import { AppletShell } from '@/components/applet-shell'
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 [showCreateModal, setShowCreateModal] = useState(false)
const [newTask, setNewTask] = useState({ title: '', description: '' })
const loadTasks = useCallback(async () => {
setLoading(true)
try {
setTasks(await taskService.findAll())
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadTasks()
}, [loadTasks])
const handleCreate = async () => {
await taskService.create(newTask)
setShowCreateModal(false)
setNewTask({ title: '', description: '' })
await loadTasks()
}
const handleComplete = async (id: string) => {
await taskService.markAsCompleted(id)
await loadTasks()
}
const handleDelete = async (id: string) => {
await taskService.delete(id)
await loadTasks()
}
const columns: ColumnDef<Task>[] = [
{ accessorKey: 'title', header: '標題' },
{ accessorKey: 'description', header: '描述' },
{
accessorKey: 'completed',
header: '狀態',
cell: ({ row }) => (
<Badge color={row.original.completed ? 'success' : 'warning'} variant="soft">
{row.original.completed ? '已完成' : '進行中'}
</Badge>
),
},
{
id: 'actions',
header: '操作',
enableSorting: false,
cell: ({ row }) => (
<div className="flex gap-2">
{!row.original.completed && (
<Button
density="tight"
variant="outline"
color="success"
onClick={() => void handleComplete(row.original.id)}
>
完成
</Button>
)}
<Button
density="tight"
variant="outline"
color="error"
warning="確定要刪除此任務嗎?"
onClick={() => void handleDelete(row.original.id)}
>
刪除
</Button>
</div>
),
},
]
return (
<AppletShell
basePath="/tasks"
actions={[{ path: '/tasks', icon: ListTodo, label: '任務列表' }]}
extraButtons={
<Button
shape="square"
density="compact"
color="primary"
title="新增任務"
onClick={() => setShowCreateModal(true)}
>
<Plus className="size-5" />
</Button>
}
>
<DataTable
data={tasks}
columns={columns}
loading={loading}
showPagination={false}
/>
<Dialog
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="新增任務"
footer={
<>
<Button variant="outline" onClick={() => setShowCreateModal(false)}>
取消
</Button>
<Button color="primary" onClick={() => void handleCreate()} disabled={!newTask.title}>
建立
</Button>
</>
}
>
<div className="space-y-6">
<Input
label="標題"
value={newTask.title}
onChange={(event) => setNewTask({ ...newTask, title: event.target.value })}
required
/>
<Textarea
label="描述"
value={newTask.description}
onChange={(event) => setNewTask({ ...newTask, description: event.target.value })}
minRows={3}
/>
</div>
</Dialog>
</AppletShell>
)
}
步驟 5:註冊路由
在 src/routes/AppRoutes.tsx 中添加路由:
import { Routes, Route } from 'react-router';
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:3000/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