Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
| 核心瀏覽 | ✅ 登入、營運總覽 Dashboard、即時車隊地圖、訂單列表/詳情+軌跡回放(含日期/關鍵字篩選)、司機列表、日報表(可匯出 CSV) |
| 寫入操作 | ✅ 司機啟停、派單參數、強制取消(2026-07-08) |
| 韌性 | ✅ 全域 Error Boundary、JWT `exp` 主動登出(2026-07-10) |
| 測試 | ✅ **26 測試檔/134 tests**(2026-07-28;含 Dashboard/tokens/csv/ErrorBoundary/`DriverDetailPage`) |
| 測試 | ✅ **27 測試檔/143 tests**(2026-07-30;含 Dashboard/tokens/csv/ErrorBoundary/`DriverDetailPage`/`writeError`) |
| 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/`) |

Expand All @@ -31,6 +31,74 @@

---

## ⏱️ 寫入結果不明時的處理(2026-07-30,跨 repo)

> 起因:fleet-app 從第五輪起陸續把 App 端每一條寫入都補上「逾時對帳」,
> 而 app 的 TODO 早就記著「**admin 端完全沒有逾時對帳的概念**,值得盤點一次」。
> 這一輪就是那次盤點——**先取證再修**,證據是真瀏覽器 + 吃掉回應的代理
> ([`line-fleet-app/tool/lossy_proxy.py`](../../line-fleet-app/tool/lossy_proxy.py))。

### 盤點結果:九條寫入路徑,真正會害人的是「強制取消」

`src/api/admin.ts` 的寫入端點共九支。多數**形狀上就是冪等的**
(PUT 費率/派單參數、PATCH `paid`/`enabled` 都是設定成某個值,重送會收斂),
所以 App 那套「查一次狀態再判斷生效沒」的對帳在這裡是過度設計。
真正的問題只有一個,而且九條全中:

**`onError` 只跳一則錯誤訊息,從來不重新讀後端。** 於是逾時之後畫面停在舊狀態,
訊息還寫著「請求逾時,**請稍後再試**」——把操作者推向唯一錯誤的下一步。

### 實跑重現(`ride #30`,blackhole `POST:/api/admin/rides/\d+/cancel`)

1. 代理 log:`上游回 HTTP/1.1 200 OK,不交還 App` = **後端真的取消了**(DB `status=9`)。
2. 15 秒後畫面:紅色 toast「請求逾時,請稍後再試」、狀態欄仍是「前往接客」、
**確認對話框還開著**。
3. 操作者照著訊息再按一次 → **HTTP 500 `訂單狀態已變更,無法取消`**。

也就是說:乘客與司機都已經收到取消通知了,後台卻連按兩次、看到兩則錯誤,
最後以為「這張單沒取消掉」。

### 修法(兩端一起)

**後端** dispatch [PR #69](https://github.com/thothawei/fleet-dispatch/pull/69):
`AdminHandler.CancelRide` 把 `ErrRideStarted`/`ErrRideStateChanged` 從 500 改判 **409**
(乘客端 `readStatusForErr` 早就是這樣分類,admin 這支是漏網的)。
沒有 409,前端根本分不出「伺服器壞了」與「這件事已經生效了」。

**前端**(本 repo)新增 `src/utils/writeError.ts`:

- `isUncertainWrite(err)`:**連線類(沒有 response)或 409** = 這次寫入結果不明。
- `handleWriteError(err, fallback, { notify, queryClient, invalidate })`:
結果不明 → **重讀指定的 query + 用不宣稱失敗的措辭**
(「沒有收到後端回應,這次操作可能已經生效——畫面已重新整理,請確認結果後再決定是否重試」);
明確的失敗(400/403/500)→ 維持原本行為,顯示原因、不動畫面。
**顯示訊息與重新整理綁在同一支函式**,否則遲早出現「訊息說已重新整理、其實沒有」的謊。
- 九條寫入路徑全部改用它(訂單取消、車輛審核、司機啟停、費率、派單參數、會費帳單兩支、帳號兩支)。
- **確認對話框在送出後一律關閉**(訂單強制取消、車輛核准/退回):結果由 toast 說明、
畫面已重讀,留著對話框只會誘導再按一次。原因沒填那種**表單驗證**仍然留著對話框。

### 驗收

- `tsc -b`/`oxlint`/`vite build` 綠;**Vitest 143 passed**(134 + 新 9)。
- **反向確認**:把 `handleWriteError` 的重讀拿掉 → 只有新加的 3 案 FAIL,其餘照過。
- **真瀏覽器 E2E(Claude Browser + 本機 dispatch + lossy proxy)**:
- 逾時(`ride #33`):代理吃掉回應 → 15 秒後**對話框自動關閉**、
toast「沒有收到後端回應,這次操作可能已經生效——畫面已重新整理…」、
狀態欄當場變「已取消」、完成時間出現、「強制取消」按鈕消失。
代理 log 可見逾時後那次 `GET /api/admin/rides/33`。
- 409(`ride #34`):先用 API 在後台背後取消,畫面還停在「前往接客」→ 按強制取消 →
`POST … -> 409 Conflict` → **同一秒**接著 `GET /api/admin/rides/34` → 畫面翻成「已取消」。
(這條的 toast 文案由單元測試斷言;瀏覽器這邊證的是 409→重讀→畫面正確這條鏈。)

### 沒做、以及為什麼

- **沒有把 App 那套「查一次狀態確認生效」搬過來**:admin 的寫入多為冪等設定,
重讀畫面就足以讓操作者判斷,再加一層判準只是多一組會過時的規則。
- **`GenerateMembershipInvoices` 本來就冪等**(後端同月重跑只回 `created: 0`),
維持現狀即可,不特別處理。

---

## 一、已完成 ✅

- [x] **C2. 司機審核啟用/停用 UI** — `DriversPage.tsx`:Switch + 停用確認 + `patchDriverStatus`
Expand Down
26 changes: 21 additions & 5 deletions src/pages/DriversPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
VEHICLE_REVIEW_STATUS,
VEHICLE_TYPE_LABEL,
} from '../constants';
import { apiError } from '../utils/apiError';
import { handleWriteError } from '../utils/writeError';

const STATUS_OPTIONS = [
{ value: 'all', label: '全部狀態' },
Expand Down Expand Up @@ -78,7 +78,12 @@ export default function DriversPage() {
message.success('已更新司機狀態');
queryClient.invalidateQueries({ queryKey: ['drivers'] });
},
onError: (err) => message.error(apiError(err, '操作失敗')),
onError: (err) =>
handleWriteError(err, '操作失敗', {
notify: message,
queryClient,
invalidate: [['drivers']],
}),
});

// 車輛審核(O5):核准/退回。退回必附原因(後端也擋)。
Expand All @@ -89,7 +94,12 @@ export default function DriversPage() {
message.success(vars.approve ? '已核准車輛' : '已退回車輛');
queryClient.invalidateQueries({ queryKey: ['drivers'] });
},
onError: (err) => message.error(apiError(err, '審核失敗')),
onError: (err) =>
handleWriteError(err, '審核失敗', {
notify: message,
queryClient,
invalidate: [['drivers']],
}),
});

const approveVehicle = (driver: Driver) => {
Expand All @@ -98,7 +108,9 @@ export default function DriversPage() {
content: `車種 ${VEHICLE_TYPE_LABEL[driver.VehicleType] ?? driver.VehicleType}/車牌 ${driver.PlateNumber}。核准後即可開始接單。`,
okText: '核准',
cancelText: '取消',
onOk: () => reviewMutation.mutateAsync({ id: driver.ID, approve: true }),
// 失敗也關掉對話框(與訂單強制取消同一個理由):清單在結果不明時已重讀過後端,
// 再按一次核准多半只會換來 409「這台車不是待審核狀態」。
onOk: () => reviewMutation.mutateAsync({ id: driver.ID, approve: true }).catch(() => undefined),
});
};

Expand All @@ -123,7 +135,11 @@ export default function DriversPage() {
message.error('退回必須填寫原因');
return Promise.reject(new Error('note required'));
}
return reviewMutation.mutateAsync({ id: driver.ID, approve: false, note: note.trim() });
// 原因沒填是上面那個 reject(對話框要留著讓他補打);
// 送出之後的結果一律關掉對話框,理由同核准。
return reviewMutation
.mutateAsync({ id: driver.ID, approve: false, note: note.trim() })
.catch(() => undefined);
},
});
};
Expand Down
9 changes: 7 additions & 2 deletions src/pages/FeeSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Alert, App, Button, Card, Form, InputNumber, Skeleton } from 'antd';

import { fetchFeeSettings, updateFeeSettings, type FeeSettings } from '../api/admin';
import { isSuperadmin } from '../auth/auth';
import { apiError } from '../utils/apiError';
import { handleWriteError } from '../utils/writeError';

// 表單以「元 / %」為單位(對使用者友善),送出前換算回後端的「分 / bps」。
interface FeeFormValues {
Expand Down Expand Up @@ -65,7 +65,12 @@ export default function FeeSettingsPage() {
form.setFieldsValue(toForm(updated));
queryClient.setQueryData(['fee-settings'], updated);
},
onError: (err) => message.error(apiError(err, '更新失敗')),
onError: (err) =>
handleWriteError(err, '更新失敗', {
notify: message,
queryClient,
invalidate: [['fee-settings']],
}),
});

if (isLoading) {
Expand Down
16 changes: 13 additions & 3 deletions src/pages/MembershipInvoicesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '../api/admin';
import PageHeader from '../components/PageHeader';
import { isSuperadmin } from '../auth/auth';
import { apiError } from '../utils/apiError';
import { handleWriteError } from '../utils/writeError';
import { fmtYuan } from '../utils/money';

const STATUS_OPTIONS = [
Expand Down Expand Up @@ -49,7 +49,12 @@ export default function MembershipInvoicesPage() {
message.success(created > 0 ? `已產生 ${created} 筆會費帳單` : '本月無新帳單可產生(已是最新)');
invalidate();
},
onError: (err) => message.error(apiError(err, '產生帳單失敗')),
onError: (err) =>
handleWriteError(err, '產生帳單失敗', {
notify: message,
queryClient,
invalidate: [['membership-invoices', monthStr]],
}),
});

const paidMutation = useMutation({
Expand All @@ -58,7 +63,12 @@ export default function MembershipInvoicesPage() {
message.success(paid ? '已標記為已繳' : '已改為未繳');
invalidate();
},
onError: (err) => message.error(apiError(err, '更新失敗')),
onError: (err) =>
handleWriteError(err, '更新失敗', {
notify: message,
queryClient,
invalidate: [['membership-invoices', monthStr]],
}),
});

const columns: ColumnsType<MembershipInvoice> = [
Expand Down
90 changes: 90 additions & 0 deletions src/pages/OrderDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AxiosError, AxiosHeaders } from 'axios';

import OrderDetailPage from './OrderDetailPage';
import { setRole } from '../auth/auth';
import { renderWithProviders } from '../test/render';

/** 逾時/斷線:請求送出去了,但沒有任何回應——後端可能已經做完了。 */
function connectionError(): AxiosError {
return new AxiosError('timeout of 15000ms exceeded', 'ECONNABORTED');
}

/** 後端有明確回答的失敗。 */
function responseError(status: number, message: string): AxiosError {
const err = new AxiosError('failed');
err.response = {
status,
statusText: '',
data: { error: message },
headers: new AxiosHeaders(),
config: { headers: new AxiosHeaders() },
};
return err;
}

/** 進行中(可取消)的訂單詳情。 */
function activeRideFixture() {
return {
ride: {
id: 2,
customer_id: 10,
driver_id: 2,
status: 1,
pickup_point: { lat: 25.034, lng: 121.566 },
pickup_address: '台北101',
dropoff_point: null,
dropoff_address: '',
requested_at: '2026-07-06T14:53:13+08:00',
accepted_at: null,
picked_up_at: null,
completed_at: null,
distance_m: null,
eta_pickup_sec: 100,
created_at: '2026-07-06T14:53:13+08:00',
updated_at: '2026-07-06T14:53:13+08:00',
},
track_geojson: '{"type":"LineString","coordinates":[]}',
events: [],
};
}

const mockFetchRideDetail = vi.fn();
const mockCancelRideByAdmin = vi.fn();
const mockFetchRideMessages = vi.fn();
Expand Down Expand Up @@ -280,6 +326,50 @@ describe('OrderDetailPage', () => {
expect(screen.queryByRole('button', { name: /強制取消/ })).not.toBeInTheDocument();
});

// 取消的回應在網路上遺失時,後端其實**已經取消了**。
// 舊行為是一則「請求逾時,請稍後再試」+畫面停在舊狀態,操作者只能再按一次 →
// 換來一則「訂單狀態已變更,無法取消」。這兩案釘住新行為:重讀後端 + 不說失敗。
it('取消逾時(結果不明)要重讀訂單,而不是停在舊狀態', async () => {
setRole('superadmin');
mockFetchRideDetail.mockResolvedValue(activeRideFixture());
mockCancelRideByAdmin.mockRejectedValue(connectionError());

renderWithProviders(<OrderDetailPage />, { route: '/orders/2', path: '/orders/:id' });
await waitFor(() => {
expect(screen.getByRole('button', { name: /強制取消/ })).toBeInTheDocument();
});
const readsBefore = mockFetchRideDetail.mock.calls.length;

const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /強制取消/ }));
await user.click(await screen.findByRole('button', { name: /確認取消/ }));

await waitFor(() => {
expect(mockFetchRideDetail.mock.calls.length).toBeGreaterThan(readsBefore);
});
expect(await screen.findByText(/可能已經生效/)).toBeInTheDocument();
expect(screen.queryByText('請求逾時,請稍後再試')).not.toBeInTheDocument();
});

it('明確的失敗(400)維持原樣:顯示原因、不重讀', async () => {
setRole('superadmin');
mockFetchRideDetail.mockResolvedValue(activeRideFixture());
mockCancelRideByAdmin.mockRejectedValue(responseError(400, '參數錯誤'));

renderWithProviders(<OrderDetailPage />, { route: '/orders/2', path: '/orders/:id' });
await waitFor(() => {
expect(screen.getByRole('button', { name: /強制取消/ })).toBeInTheDocument();
});
const readsBefore = mockFetchRideDetail.mock.calls.length;

const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /強制取消/ }));
await user.click(await screen.findByRole('button', { name: /確認取消/ }));

expect(await screen.findByText('參數錯誤')).toBeInTheDocument();
expect(mockFetchRideDetail.mock.calls.length).toBe(readsBefore);
});

it('顯示乘客指定車種與司機當時的車輛快照(兩者是不同欄位)', async () => {
mockFetchRideDetail.mockResolvedValue({
ride: {
Expand Down
14 changes: 11 additions & 3 deletions src/pages/OrderDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import {
} from '../constants';

import { DEFAULT_MAP_CENTER, MAP_HEIGHT, MAP_STYLE } from '../components/mapStyle';
import { apiError } from '../utils/apiError';
import { fmtYuan } from '../utils/money';
import { handleWriteError } from '../utils/writeError';
const TRACK_SOURCE_ID = 'ride-track';
const TRACK_LAYER_ID = 'ride-track-line';
const PLAYBACK_INTERVAL_MS = 300;
Expand Down Expand Up @@ -78,7 +78,12 @@ export default function OrderDetailPage() {
queryClient.invalidateQueries({ queryKey: ['ride', rideId] });
queryClient.invalidateQueries({ queryKey: ['rides'] });
},
onError: (err) => message.error(apiError(err, '取消失敗')),
onError: (err) =>
handleWriteError(err, '取消失敗', {
notify: message,
queryClient,
invalidate: [['ride', rideId], ['rides']],
}),
});

const confirmCancel = () => {
Expand All @@ -88,7 +93,10 @@ export default function OrderDetailPage() {
okText: '確認取消',
okButtonProps: { danger: true },
cancelText: '返回',
onOk: () => cancelMutation.mutateAsync(),
// **失敗也關掉對話框**:結果由 onError 的訊息負責說明,畫面(結果不明時)
// 也已經重讀過後端——操作者該看的是那個結果,不是留在對話框上再按一次確認,
// 那一按多半只會換來另一則「訂單狀態已變更」。要重試,頁面上的按鈕還在。
onOk: () => cancelMutation.mutateAsync().catch(() => undefined),
});
};

Expand Down
9 changes: 7 additions & 2 deletions src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type DispatchSettings,
} from '../api/admin';
import { canDispatch } from '../auth/auth';
import { apiError } from '../utils/apiError';
import { handleWriteError } from '../utils/writeError';

const FIELD_RULES = {
radius_m: { min: 100, max: 50000, label: '搜尋半徑(公尺)' },
Expand Down Expand Up @@ -39,7 +39,12 @@ export default function SettingsPage() {
form.setFieldsValue(updated);
queryClient.setQueryData(['dispatch-settings'], updated);
},
onError: (err) => message.error(apiError(err, '更新失敗')),
onError: (err) =>
handleWriteError(err, '更新失敗', {
notify: message,
queryClient,
invalidate: [['dispatch-settings']],
}),
});

if (isLoading) {
Expand Down
Loading
Loading