Planner Studio 前端完整實作指南¶
簡介¶
Planner Studio 是一款零框架微前端應用,採用 Vanilla JavaScript + Semantic HTML + Modular CSS 設計模式。該應用用於視覺化時間規劃建議、管理日曆事件衝突,並支援多方案互動與即時套用。
零框架設計的價值主張¶
優勢: - 極速啟動:無編譯步驟,直接解析 ES Modules,首屏時間 < 500ms - 依賴最小:僅依賴 FullCalendar 6.1(CDN 引入)與 RRule 2.8(日期遞迴) - 實時性強:無虛擬 DOM 層,DOM 操作直接,支援 60fps 縮放動畫 - 風格靈活:主題切換零開銷,CSS 變數驅動,支援亮色/深色/綠色三主題
局限性: - 狀態管理依賴手動同步(全域 state 物件) - 大規模表單互動需要手寫事件委派邏輯 - 無內建路由(當前應用單頁,URL 中含 Session ID) - 無自動水合 (Hydration),SSR 不適用
架構設計¶
Planner Studio
├── 入口層 (Bootstrap)
│ └── main.js: 解析 URL、驗證配置、啟動 ui.init()
│
├── 核心層 (Core)
│ ├── state.js: 全域狀態 + DOM 元素緩存
│ ├── utils.js: 無狀態工具函式(9 個純函式)
│ └── contracts/planner_payload.schema.js: Zod 驗證契約
│
├── 特性層 (Features)
│ ├── view.js: UI 渲染、互動邏輯(991 行)
│ │ ├── 日曆渲染 (FullCalendar 初始化)
│ │ ├── 方案面板更新
│ │ ├── 主題切換
│ │ └── 事件監聽樞紐
│ └── zoom.js: 物理縮放引擎(Shift+Wheel 交互)
│
├── 服務層 (Services)
│ └── api.js: 後端通訊、Payload 規範化、快取管理
│
└── 樣式層 (Styles)
├── main.css: 主入口(@import 聚合)
├── main-light/dark/green.css: 主題選擇器
├── themes/theme.*.css: CSS 變數定義
├── base/
│ ├── layout.css: 主容器、Toolbar、Sidebar
│ ├── calendar.css: FullCalendar 調整 (280 行)
│ └── components.css: 按鈕、卡片、標籤
└── planner_studio.html: 語意化標記 + 內聯樣式
資料流圖¶
User Action
↓
ui.initListeners() → event handler
↓
api.shiftVariant() / api.fetchContext() / api.applyCurrentVariant()
↓
state.payload 更新
↓
ui.renderState() → ui.renderCalendar() →
├─ FullCalendar.setOption()
├─ ui.renderCalendarsSelect()
└─ ui.renderCalendarControls()
↓
DOM 反映最新狀態
模組分解¶
1. state.js — 全域狀態與 DOM 參考¶
職責:集中管理應用狀態與 DOM 元素快取,避免重複查詢 .getElementById()。
export const state = {
payload: null, // PlannerPayload (完整規劃數據)
variantIndex: 0, // 當前方案索引
calendar: null, // FullCalendar 實例
sessionId: null, // URL 提取的 Session ID
applying: false, // 套用中標誌
hiddenCalendars: new Set(), // 隱藏的日曆 ID
currentView: "timeGridDay", // 日/週視圖
currentDate: null, // YYYY-MM-DD
};
export const elements = {
prev, next, pill, conflict, applyBtn,
calendarRoot, side, variantPanel, // 主要容器
draftText, variantTitle, variantDesc, // 文本區域
modalBackdrop, // 事件詳情彈窗
// ... 共 30+ 個元素引用
};
最佳實踐: - 查詢一次後快取,避免 DOM 樹遍歷重複 - 使用 JSDoc Types 標註複雜物件結構 - 所有客戶端狀態集中於 state.payload
2. utils.js — 無狀態工具函式庫¶
職責:提供跨模組的純函式,無副作用,易於測試。
| 函式 | 用途 |
|---|---|
formatHoursMinutes(date) | "14:30" 格式 |
durationMinutes(start, end) | 計算時間差(分鐘) |
formatDurationZh(start, end) | "1小時30分鐘" 中文語義 |
checkTimeOverlap(aS, aE, bS, bE) | 時間段衝突檢測 |
parseDate(v) | 日期字串 → 毫秒 |
easeOutCubic(t) | 三次緩動函式 (0 to 1) |
getCumulativeScaleY(element) | 遞迴計算 CSS Transform Y 縮放 |
normalizeWheelDelta(event) | 滑鼠滾輪標準化 |
設計原則: - 不依賴 DOM 或全域狀態 - 參數型別使用數字或基本型別 - 返回值明確,無 undefined 歧義
3. contracts/planner_payload.schema.js — 前後端契約¶
職責:使用 Zod 驗證 API 回應,防止 Runtime TypeError。
export const PlannerPayloadSchema = z.object({
session_id: z.string(),
created_at: z.string(),
start_time: z.string(), // ISO 8601
end_time: z.string(),
view_date: z.string(), // YYYY-MM-DD
ai_draft: z.string(), // Markdown 文本
calendar_events: z.array(PlannerCalendarEventSchema),
draft_variants: z.array(DraftVariantSchema),
writable_calendars: z.array(WritableCalendarSchema),
apply_state: ApplyStateSchema,
// ... 包含日曆、任務、Moodle 上下文
});
契約詳解:
PlannerCalendarEvent — 已存在的日曆事項¶
{
id: string; // 全域唯一 ID
calendar_id: string | null; // Google Calendar ID
title: string;
start: string; // ISO 8601
end: string;
all_day: boolean;
location: string;
description: string;
color_class: string; // "cal-color-1" ~ "cal-color-8"
rrule: string | null; // "FREQ=WEEKLY;BYDAY=MO,WE,FR"
recurring_event_id: string | null; // 主雜迴事件 ID
original_start: string | null; // 異常實例原定時間
status: string | null; // "cancelled" 表示被刪除
}
DraftVariant — AI 產生的方案¶
{
variant_id: string;
title: string; // "方案 A: 深度工作集中"
description: string; // Markdown 敍述+推理
events: Array<{
summary: string;
start: string; // ISO 8601
end: string;
all_day: boolean;
location: string | null;
description: string | null;
transparency: string; // "opaque" (占用) | "transparent"
recurrence: string[] | null; // ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"]
}>;
}
ApplyState — 套用履歷¶
{
applied: boolean;
applying: boolean;
applied_variant_index: number | null;
applied_calendar_id: string | null;
applied_at: string | null; // ISO 8601
}
前後端契約詳解¶
請求 Schema¶
1. 初始載入¶
GET /api/planner/{sessionId}
回應: PlannerPayload
2. 日期導航¶
POST /api/context/fetch
{
"start_time": "2026-01-15T00:00:00Z",
"end_time": "2026-01-22T00:00:00Z",
"view_date": "2026-01-15",
"session_id": "sess_abc123"
}
視窗規則: - 日視圖: 前後各 7 天 - 週視圖: 前後各 3 週
3. 套用方案¶
POST /api/planner/{sessionId}/apply
{
"variant_index": 0,
"calendar_id": "user@gmail.com",
"allow_conflicts": true
}
回應:
{
"applied_variant_index": 0,
"calendar_id": "user@gmail.com",
"created_count": 5
}
互動流程與狀態轉移¶
使用者操作流圖¶
┌─ 應用啟動 ──→ loadPayload() ──→ hydrate(payload)
│ ↓
│ renderCalendar()
│ renderState()
│
├─ 用戶操作
│ ├─ 點擊「下一方案」
│ │ └─ ui.next ──→ api.shiftVariant(+1) ──→ state.variantIndex ++ ──→ ui.renderState()
│ │
│ ├─ Shift + 滾輪
│ │ └─ wheel 事件 ──→ zoom 動畫 ──→ zoomEngine.stepZoomFrame() ──→ calendar.updateSize()
│ │
│ ├─ 點擊日期導航
│ │ └─ btnNextDate ──→ api.fetchContext(newDate)
│ │ ──→ HTTP POST /api/context/fetch
│ │ ──→ _findContextCache() → _hydratePayload()
│ │
│ ├─ 點擊「確定採用此規劃」
│ │ └─ applyBtn ──→ api.applyCurrentVariant()
│ │ ──→ HTTP POST /api/planner/{id}/apply
│ │ ──→ state.payload.applyState.applied = true
│ │ ──→ ui.renderState() (按鈕變灰、顯示「已套用」)
│ │
│ └─ 主題切換
│ └─ themeToggleBtn ──→ ui.applyTheme(nextMode)
│ ──→ localStorage.setItem()
│ ──→ calendar.render()
│
└─ 清理 (卸載)
└─ api 停止輪詢、事件監聽移除
效能考量與優化¶
1. DOM 操作批次化¶
反面例子:
// ❌ 低效:每次迭代新增一個元素
for (const cal of calendars) {
container.appendChild(createCalendarToggle(cal)); // 觸發 reflow
}
改善:
// ✅ 高效:先構建 DOM,集中插入
const fragment = document.createDocumentFragment();
for (const cal of calendars) {
fragment.appendChild(createCalendarToggle(cal));
}
container.appendChild(fragment); // 單次 reflow
2. 縮放動畫無卡頓策略¶
// Shift 鍵時,保持鼠標下的分鐘數恆定
zoomEngine.scrollMinuteToClientY(
zoom.cursorMinute,
zoom.cursorClientY,
blend = 1, // blend 為 1 表示完全追蹤
currentPpm
);
// 使用 CSS 變數驅動尺寸,讓瀏覽器自動優化
fcRoot.style.setProperty("--slot-height", `${ppm * 15}px`);
3. 快取策略¶
- Context 快取:5 分鐘 TTL,大弧度時間窗判定(避免微小日期變更重新請求)
- SessionStorage 快取:儲存最後一次生成的方案,頁面重整後復原
- 元素快取:
elements物件預快取 30+ 元素引用
4. 常見陷阱¶
| 陷阱 | 症狀 | 解決方案 |
|---|---|---|
| Midnight Sliver 文本重疊 | 跨日期事件文字糊在一起 | 使用 fc-event-boundary-sliver 類,調整 flex-direction |
| 縮放後日曆不重繪 | 時槽尺寸變了但 FullCalendar 不覺得 | calendar.updateSize() + calendar.render() |
| 主題切換時事件樣式不變 | 深色模式仍是淺色文字 | 主題 CSS 使用 var() 變數,在 data-theme 變時更新 |
| 快取導致方案舊資料 | 更新方案後看到舊版本 | SessionStorage 快取在 Session 變更時自動清除 |
開發指南:新增功能¶
案例 1:新增「導出到 ICS」功能¶
步驟 1:建立新 Utility¶
// js/utils.js 新增
export function buildIcsCalendar(events) {
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//TimeCompass///',
];
for (const e of events) {
ics.push('BEGIN:VEVENT');
ics.push(`DTSTART:${toIcsDate(e.start)}`);
ics.push(`SUMMARY:${escapeIcs(e.summary)}`);
ics.push('END:VEVENT');
}
ics.push('END:VCALENDAR');
return ics.join('\r\n');
}
步驟 2:在 view.js 掛入事件¶
// view.js
const exportBtn = document.getElementById("exportBtn");
exportBtn.addEventListener("click", () => {
const events = state.payload?.draftVariants?.[state.variantIndex]?.events || [];
const ics = buildIcsCalendar(events);
const blob = new Blob([ics], { type: 'text/calendar' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `planner_${state.currentDate}.ics`;
a.click();
});
調試技巧¶
啟用追蹤日誌¶
在 URL 後加 ?trace_planner=1,如:
http://localhost:8766/planner/sess_abc123?trace_planner=1
輸出範例:
[planner-trace][view] renderState() variantIndex=0
[planner-trace][api] hydrate-payload sessionId=sess_abc123 draftVariants=3
常用 DevTools 命令¶
// 檢視目前狀態
console.log(state);
// 手動觸發方案切換
api.shiftVariant(1);
// 清除快取
state.contextCache = [];
// 強制重繪日曆
state.calendar?.render();
最佳實踐總結¶
通用原則¶
- 狀態單一源:所有應用狀態存放在
state.payload,不要分散 - 純函式優先:工具函式應無副作用,易於測試
- CSS 變數驅動:動態尺寸、顏色皆透過
--var-name定義 - 事件委派:若有動態 DOM,使用事件委派而非直接綁定
- 快取結合 TTL:API 呼叫加快取,但需設定失效期限
命名規範¶
- 全域狀態物件:
state.* - UI 渲染方法:
ui.render*() - API 服務方法:
api.[loadXxx|fetchXxx|applyXxx]() - 工具函式:
[verb][Noun](),如formatHoursMinutes、checkTimeOverlap
附錄:完整模組依賴圖¶
main.js (入口)
↓
state.js (狀態+快取) ←─────────────────────┐
↑ │
│ │
view.js (UI 渲染) ←───┐ api.js (服務層) ├─ zoom.js
│ │ ↓ │
└──→ utils.js (工具) → contracts/ ├─ planner_payload.schema.js
planner_payload.schema.js ←───────┘
更新日期:2026年3月2日 | Planner Studio Frontend Reference