diff --git a/docs/TODO.md b/docs/TODO.md
index 518db28..eb589f1 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,6 +1,6 @@
# line-fleet-admin — 缺口清單
-> 建立:2026-07-08。最後盤點:2026-07-10(以程式碼實測為準)。
+> 建立:2026-07-08。最後盤點:**2026-07-28**(以程式碼實測為準)。
> 編號沿用後端 repo 的 [gap-analysis-plan](../../line-fleet-dispatch/docs/2026-07-07-gap-analysis-plan.md)(C=後台前端、D=後端)。
> 每完成一項:實跑驗收 → 勾選回填 → commit + push(main)。
@@ -13,12 +13,22 @@
| 核心瀏覽 | ✅ 登入、營運總覽 Dashboard、即時車隊地圖、訂單列表/詳情+軌跡回放(含日期/關鍵字篩選)、司機列表、日報表(可匯出 CSV) |
| 寫入操作 | ✅ 司機啟停、派單參數、強制取消(2026-07-08) |
| 韌性 | ✅ 全域 Error Boundary、JWT `exp` 主動登出(2026-07-10) |
-| 測試 | ✅ 19 測試檔/76 tests(含 Dashboard/tokens/csv/ErrorBoundary) |
+| 測試 | ✅ **26 測試檔/134 tests**(2026-07-28;含 Dashboard/tokens/csv/ErrorBoundary/`DriverDetailPage`) |
| CI | ✅ lint + test + build(`.github/workflows/ci.yml`);2026-07-10 修好 `npm ci` ERESOLVE |
| 視覺驗收 | ✅ C5(2026-07-08)+ UI/UX 翻新腳本已對齊新路由(`docs/screenshots/ux-2026-07-10/`) |
**一句話**:「看」已齊全;「管」核心寫入(C2/C3/強制取消)已串接 P2 API。
+### 2026-07-28 勾選稽核(過期未勾)
+
+本檔的四個 `[ ]`(3.3 登出確認、3.5 點擊司機連動、3.6 搜尋/篩選、3.6 司機詳情頁)
+**其實全部早就實作了**,只是沒回填。逐條對照程式碼確認後補勾(各項證據見下方條目)。
+
+**但其中三項先前完全沒有測試**——「做了沒勾」的代價不只是文件難讀,而是
+**沒人知道它該有測試**:司機詳情頁連測試檔都不存在、popup 連結與登出確認也都沒被覆蓋。
+同日補上 10 個測試(124 → **134 passed**),FleetPage 兩案做過反向驗證
+(拿掉 popup 連結/拿掉委派 handler,各自只讓對應那案 FAIL)。
+
---
## 一、已完成 ✅
@@ -78,7 +88,9 @@
`RequireAuth` 依 `exp` 排到期鬧鐘(delay clamp 到 2^31-1,否則 setTimeout 溢位變立即觸發),
處理「停在頁面上不發請求、沒有 401 可觸發登出」的情境。
- [x] **已登入導向** — LoginPage 已登入自動導回首頁
-- [ ] **登出確認** — 可選,避免誤觸
+- [x] **登出確認** ✅ 已做(2026-07-28 查證補勾)— `AppLayout` 的登出走 `App.useApp()` 的
+ `modal.confirm`(「確定要登出?」),確認才 `clearSession()` + 導向 `/login`。
+ **先前沒有任何測試**,同日補 2 案(取消不清 session/確認才清)。
### 3.4 訂單瀏覽增強
@@ -97,15 +109,24 @@
### 3.5 車隊地圖增強
- [x] **Marker popup 資訊** — 姓名、狀態、更新時間
-- [ ] **點擊司機連動** — 導向司機列表或詳情
+- [x] **點擊司機連動** ✅ 已做(2026-07-28 查證補勾)— popup 內「查看司機 →」,
+ 以**委派點擊**(`[data-driver-link]`)走 SPA 導向 `/drivers/:id`,`href` 保留當 JS 失效的退路。
+ **先前沒有任何測試**,同日補 3 案(popup 帶連結與正確 id/點連結走 SPA 導向/
+ 點地圖其他位置不導向);兩案都做過反向驗證(拿掉連結或拿掉 handler 各自 FAIL)。
- [x] **共用 Map 元件** — `src/components/mapStyle.ts`
- [x] **地圖視野** — fitBounds 依司機點位
### 3.6 司機管理增強
- [x] **normalizeDriver** — `fetchDrivers` 兼容 PascalCase/snake_case
-- [ ] **搜尋/篩選** — 姓名、電話、狀態
-- [ ] **司機詳情頁** — `/drivers/:id`(後端若有單筆端點再串;否則用列表資料)
+- [x] **搜尋/篩選** ✅ 已做(2026-07-28 查證補勾)— `Input.Search`「搜尋姓名/電話/車牌」
+ (比原規劃多了車牌)+狀態下拉(含「待審核」)+車種下拉(含「未填車輛」)。
+ 先前只有車種篩選有測試,同日補關鍵字搜尋 1 案(姓名/電話/**車牌大小寫不敏感**三路都驗)。
+- [x] **司機詳情頁** ✅ 已做(2026-07-28 查證補勾)— `DriverDetailPage` + 路由 `/drivers/:id`。
+ **後端確實沒有單筆司機 GET**(`api/admin.ts` 只有列表/狀態/車輛審核),
+ 所以照原規劃的退路走列表資料:列表快取命中免再打,直接開網址則自己抓一次。
+ **先前完全沒有測試**,同日補 `DriverDetailPage.test.tsx` 4 案
+ (明細顯示/直接進入會自行取資料/查不到該 id 顯示「找不到司機 #999」並留兩處返回/麵包屑回列表)。
### 3.7 報表增強
diff --git a/src/components/AppLayout.test.tsx b/src/components/AppLayout.test.tsx
index 0f2ab8d..15a511e 100644
--- a/src/components/AppLayout.test.tsx
+++ b/src/components/AppLayout.test.tsx
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it } from 'vitest';
-import { screen } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import AppLayout from './AppLayout';
import { renderWithProviders } from '../test/render';
-import { clearSession, saveSession } from '../auth/auth';
+import { clearSession, isLoggedIn, saveSession } from '../auth/auth';
describe('AppLayout 選單分級', () => {
afterEach(() => {
@@ -30,3 +31,49 @@ describe('AppLayout 選單分級', () => {
expect(screen.queryByText('使用者管理')).not.toBeInTheDocument();
});
});
+
+// 登出鈕就在 Header 右上角、緊鄰使用者名稱,誤觸成本是整個 session 重登。
+// 這兩案的重點是「取消真的什麼都沒發生」——只驗確認路徑會讓誤觸防護形同虛設。
+describe('AppLayout 登出確認', () => {
+ afterEach(() => {
+ clearSession();
+ });
+
+ // ⚠️ antd 會在「剛好兩個中文字」的按鈕中間插入空格(DOM 實際是「登 出」「取 消」),
+ // 所以 name 一律用寬鬆的 /登\s*出/,精確字串比對會查不到元素。
+ // 標題同時出現在 ant-modal-title 與 ant-modal-confirm-title 兩處,
+ // 因此斷言走 dialog 的 textContent 而非 getByText(後者會 Found multiple)。
+ it('按登出先跳確認框,按取消不清 session', async () => {
+ const user = userEvent.setup();
+ saveSession('tok', '王小明', 'dispatcher');
+
+ renderWithProviders();
+
+ await user.click(await screen.findByRole('button', { name: /登\s*出/ }));
+ const dialog = await screen.findByRole('dialog');
+ expect(dialog).toHaveTextContent('確定要登出?');
+
+ await user.click(await within(dialog).findByRole('button', { name: /取\s*消/ }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ expect(isLoggedIn()).toBe(true);
+ });
+
+ it('確認後才清 session', async () => {
+ const user = userEvent.setup();
+ saveSession('tok', '王小明', 'dispatcher');
+
+ renderWithProviders();
+
+ await user.click(await screen.findByRole('button', { name: /登\s*出/ }));
+ // 確認框的主鈕文案也是「登出」,取 dialog 內那顆以免點回 Header 上的原鈕
+ const dialog = await screen.findByRole('dialog');
+ await user.click(await within(dialog).findByRole('button', { name: /登\s*出/ }));
+
+ await waitFor(() => {
+ expect(isLoggedIn()).toBe(false);
+ });
+ });
+});
diff --git a/src/pages/DriverDetailPage.test.tsx b/src/pages/DriverDetailPage.test.tsx
new file mode 100644
index 0000000..e425f32
--- /dev/null
+++ b/src/pages/DriverDetailPage.test.tsx
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import DriverDetailPage from './DriverDetailPage';
+import { renderWithProviders } from '../test/render';
+
+const mockFetchDrivers = vi.fn();
+
+vi.mock('../api/admin', () => ({
+ fetchDrivers: (...args: unknown[]) => mockFetchDrivers(...args),
+}));
+
+const driver = {
+ ID: 7,
+ Name: '王大明',
+ Phone: '0912345678',
+ LineUserID: 'line-7',
+ Status: 1,
+ VehicleType: 'sedan',
+ PlateNumber: 'ABC-1234',
+ VehicleReviewStatus: 'approved',
+ VehicleReviewNote: '',
+ RatingAvg: 4.5,
+ RatingCount: 2,
+ CreatedAt: '2026-07-20T01:02:03Z',
+ UpdatedAt: '2026-07-21T04:05:06Z',
+};
+
+describe('DriverDetailPage', () => {
+ beforeEach(() => {
+ mockFetchDrivers.mockReset();
+ mockFetchDrivers.mockResolvedValue([driver]);
+ });
+
+ it('從列表資料取出對應司機並顯示明細', async () => {
+ renderWithProviders(, { route: '/drivers/7', path: '/drivers/:id' });
+
+ // 標題用姓名,代表確實比對到了 id 而不是只印路由參數
+ expect(await screen.findByText('司機:王大明')).toBeInTheDocument();
+ expect(screen.getByText('0912345678')).toBeInTheDocument();
+ expect(screen.getByText('line-7')).toBeInTheDocument();
+ expect(screen.getByText('待命')).toBeInTheDocument();
+ });
+
+ // 直接開網址(沒經過列表)時本頁會自己抓一次;這是它能單獨被連結的前提,
+ // 例如即時車隊地圖 popup 的「查看司機 →」。
+ it('直接進入時自行呼叫 fetchDrivers', async () => {
+ renderWithProviders(, { route: '/drivers/7', path: '/drivers/:id' });
+
+ expect(await screen.findByText('司機:王大明')).toBeInTheDocument();
+ expect(mockFetchDrivers).toHaveBeenCalled();
+ });
+
+ // 後端無單筆端點,找不到只可能是「列表裡沒有這個 id」——要說清楚是哪個 id,
+ // 並留一條回列表的路,不能只給一個空白 Card。
+ it('列表中沒有該 id 時顯示找不到並提供返回', async () => {
+ renderWithProviders(, { route: '/drivers/999', path: '/drivers/:id' });
+
+ expect(await screen.findByText('找不到司機 #999')).toBeInTheDocument();
+ // 兩顆:PageHeader 右側固定有一顆,Empty 裡再給一顆——空狀態的視線焦點在中間,
+ // 只留右上角那顆會讓人以為沒有出路。
+ expect(screen.getAllByRole('button', { name: /返回司機列表/ })).toHaveLength(2);
+ });
+
+ it('麵包屑可回到司機管理', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(, { route: '/drivers/7', path: '/drivers/:id' });
+
+ expect(await screen.findByText('司機:王大明')).toBeInTheDocument();
+ const crumb = screen.getByRole('link', { name: '司機管理' });
+ expect(crumb).toHaveAttribute('href', '/drivers');
+ await user.click(crumb); // 不該丟例外(路由存在於 MemoryRouter)
+ });
+});
diff --git a/src/pages/DriversPage.test.tsx b/src/pages/DriversPage.test.tsx
index 2dac6f8..2ce36d0 100644
--- a/src/pages/DriversPage.test.tsx
+++ b/src/pages/DriversPage.test.tsx
@@ -77,6 +77,44 @@ describe('DriversPage', () => {
});
expect(screen.queryByText('寵物車司機')).not.toBeInTheDocument();
});
+
+ // 搜尋框寫著「搜尋姓名/電話/車牌」——**三個欄位都要真的能搜**。
+ // placeholder 承諾了卻搜不到比沒有搜尋更糟:使用者會以為查無此人。
+ it('關鍵字搜尋:姓名/電話/車牌三個欄位都命中', async () => {
+ const user = userEvent.setup();
+ mockFetchDrivers.mockResolvedValue([
+ { ID: 1, Name: '王大明', Phone: '0911111111', LineUserID: 'l1', Status: 1, VehicleType: 'sedan', PlateNumber: 'AAA-1111', VehicleReviewStatus: 'approved', VehicleReviewNote: '' },
+ { ID: 2, Name: '李小美', Phone: '0922222222', LineUserID: 'l2', Status: 1, VehicleType: 'pet', PlateNumber: 'PET-0002', VehicleReviewStatus: 'approved', VehicleReviewNote: '' },
+ ]);
+
+ renderWithProviders();
+ await waitFor(() => {
+ expect(screen.getByText('王大明')).toBeInTheDocument();
+ });
+
+ const search = screen.getByPlaceholderText('搜尋姓名/電話/車牌');
+
+ await user.type(search, '李小');
+ await waitFor(() => {
+ expect(screen.queryByText('王大明')).not.toBeInTheDocument();
+ });
+ expect(screen.getByText('李小美')).toBeInTheDocument();
+
+ await user.clear(search);
+ await user.type(search, '0911');
+ await waitFor(() => {
+ expect(screen.queryByText('李小美')).not.toBeInTheDocument();
+ });
+ expect(screen.getByText('王大明')).toBeInTheDocument();
+
+ // 車牌大小寫不該影響命中——路邊比對車牌時沒人在意大小寫
+ await user.clear(search);
+ await user.type(search, 'pet-0002');
+ await waitFor(() => {
+ expect(screen.queryByText('王大明')).not.toBeInTheDocument();
+ });
+ expect(screen.getByText('李小美')).toBeInTheDocument();
+ });
});
describe('DriversPage 評價欄(B5)', () => {
diff --git a/src/pages/FleetPage.test.tsx b/src/pages/FleetPage.test.tsx
index 8dc25c9..5d35b07 100644
--- a/src/pages/FleetPage.test.tsx
+++ b/src/pages/FleetPage.test.tsx
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import FleetPage from './FleetPage';
import { renderWithProviders } from '../test/render';
@@ -7,6 +8,19 @@ import { renderWithProviders } from '../test/render';
const mockFetchFleet = vi.fn();
const mockFetchDrivers = vi.fn();
+// maplibre 被 mock 掉,popup 的 HTML 不會真的進 DOM——所以把它記下來直接驗字串,
+// 並記住地圖容器(委派點擊掛在它身上),才能測到「點 popup 連結會導向詳情頁」。
+const captured = vi.hoisted(() => ({
+ container: null as HTMLElement | null,
+ popupHtml: [] as string[],
+}));
+const navigateSpy = vi.hoisted(() => vi.fn());
+
+vi.mock('react-router-dom', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useNavigate: () => navigateSpy,
+}));
+
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('maplibre-gl', () => {
const map = {
@@ -18,16 +32,29 @@ vi.mock('maplibre-gl', () => {
const marker = {
setLngLat: vi.fn().mockReturnThis(),
setPopup: vi.fn().mockReturnThis(),
- getPopup: vi.fn(() => ({ setHTML: vi.fn() })),
+ getPopup: vi.fn(() => ({
+ setHTML: vi.fn((html: string) => {
+ captured.popupHtml.push(html);
+ }),
+ })),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
};
return {
default: {
- Map: vi.fn(() => map),
+ Map: vi.fn((opts: { container: HTMLElement }) => {
+ captured.container = opts.container;
+ return map;
+ }),
Marker: vi.fn(() => marker),
Popup: vi.fn(function Popup() {
- return { setHTML: vi.fn().mockReturnThis(), setText: vi.fn().mockReturnThis() };
+ return {
+ setHTML: vi.fn(function (this: unknown, html: string) {
+ captured.popupHtml.push(html);
+ return this;
+ }),
+ setText: vi.fn().mockReturnThis(),
+ };
}),
LngLatBounds: vi.fn(function LngLatBounds() {
return { extend: vi.fn().mockReturnThis() };
@@ -52,6 +79,9 @@ vi.mock('../api/admin', () => ({
describe('FleetPage', () => {
beforeEach(() => {
+ captured.container = null;
+ captured.popupHtml = [];
+ navigateSpy.mockReset();
mockFetchFleet.mockReset();
mockFetchDrivers.mockReset();
mockFetchFleet.mockResolvedValue([{ driver_id: 1, lat: 25.03, lng: 121.56, updated_at: 100 }]);
@@ -72,4 +102,55 @@ describe('FleetPage', () => {
expect(mockFetchFleet).toHaveBeenCalled();
expect(mockFetchDrivers).toHaveBeenCalled();
});
+
+ // popup 是「地圖 → 司機詳情」唯一的入口。它是手組的 HTML 字串,
+ // 沒有型別保護,改動 popup 內容時最容易把連結弄掉,所以直接驗字串。
+ it('marker popup 帶「查看司機」連結與正確的 driver id', async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(captured.popupHtml.length).toBeGreaterThan(0);
+ });
+
+ const html = captured.popupHtml.join('\n');
+ expect(html).toContain('查看司機');
+ expect(html).toContain('data-driver-link="1"');
+ // href 要留著:委派點擊靠 JS,JS 掛掉時整頁跳轉是唯一退路
+ expect(html).toContain('href="/drivers/1"');
+ expect(html).toContain('測試司機');
+ });
+
+ it('點 popup 的連結走 SPA 導向而不是整頁跳轉', async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(captured.container).not.toBeNull();
+ });
+
+ // maplibre 被 mock,popup 不會真的掛進 DOM;此處還原它的效果——
+ // 把同一段 HTML 放進地圖容器,驗證委派 handler 真的接得到。
+ const container = captured.container as HTMLElement;
+ container.insertAdjacentHTML(
+ 'beforeend',
+ '查看司機 →',
+ );
+
+ const link = container.querySelector('[data-driver-link]') as HTMLElement;
+ await userEvent.click(link);
+
+ expect(navigateSpy).toHaveBeenCalledWith('/drivers/1');
+ });
+
+ // 反向確認:容器內其他地方的點擊不該觸發導向,否則整張地圖都變成連結
+ it('點地圖其他位置不會導向', async () => {
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(captured.container).not.toBeNull();
+ });
+
+ await userEvent.click(captured.container as HTMLElement);
+
+ expect(navigateSpy).not.toHaveBeenCalled();
+ });
});