From b755c721844f1d4915bdc6aaecfc0c110f831afd Mon Sep 17 00:00:00 2001 From: Emrys <219520811+Emryswu@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:09:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E5=8C=BA=E5=88=86=E5=B7=AE?= =?UTF-8?q?=E8=B4=A6=E5=B7=A5=E5=8D=95=E6=B5=81=E7=A8=8B=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E6=9A=82=E6=97=B6=E6=95=85=E9=9A=9C=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/components/table.tsx | 4 +- .../app/components/views/WorkOrdersView.tsx | 18 +++-- frontend/app/lib/api.ts | 4 +- frontend/app/lib/types.ts | 2 +- frontend/app/page.tsx | 4 +- frontend/tests/table-parts.test.mjs | 76 ++++++++++++++++++- frontend/tests/token-sync.test.mjs | 66 ++++++++++++++++ 7 files changed, 159 insertions(+), 15 deletions(-) diff --git a/frontend/app/components/table.tsx b/frontend/app/components/table.tsx index 6606dec..35d81e2 100644 --- a/frontend/app/components/table.tsx +++ b/frontend/app/components/table.tsx @@ -25,8 +25,8 @@ export const STATE_TONES: Record = { SUBMITTED: "warn", ASSIGNED: "info", IN_PROGRESS: "info", RESOLVED: "ok", CLOSED: "idle", }; -export function StateBadge({ status }: { status: string }) { - return {statusLabels[status] ?? status}; +export function StateBadge({ status, label = statusLabels[status] ?? status }: { status: string; label?: string }) { + return {label}; } export function CategoryTag({ category }: { category: string }) { diff --git a/frontend/app/components/views/WorkOrdersView.tsx b/frontend/app/components/views/WorkOrdersView.tsx index db36f80..72f9bd1 100644 --- a/frontend/app/components/views/WorkOrdersView.tsx +++ b/frontend/app/components/views/WorkOrdersView.tsx @@ -29,13 +29,15 @@ export function WorkOrdersView({ user, workorders, equipment, technicians, filte ?? technicians.find((item) => item.id === id)?.displayName ?? (id ? `维修员 #${id}` : "待分派"); const isOwn = (item: WorkOrder) => item.assigneeId === user.id; - const canActOn = (item: WorkOrder) => canAssign || (canProcess && isOwn(item)); + const canActOn = (item: WorkOrder) => canAssign || (item.category === "FAULT" && canProcess && isOwn(item)); /** The one forward step for this row, shown as the solid button; everything else goes in the menu. */ const primaryStep = (item: WorkOrder): { key: string; label: string; onClick: () => void } | null => { - if (canClaim && item.status === "SUBMITTED") return { key: "claim", label: "接单", onClick: () => onClaim(item) }; + if (item.category === "FAULT" && canClaim && item.status === "SUBMITTED") return { key: "claim", label: "接单", onClick: () => onClaim(item) }; if (!canActOn(item)) return null; - if (item.status === "ASSIGNED") return { key: "start", label: "开始处理", onClick: () => onTransition(item, "IN_PROGRESS") }; + if ((item.category === "FAULT" && item.status === "ASSIGNED") || (item.category === "PAYMENT_DISCREPANCY" && item.status === "SUBMITTED")) { + return { key: "start", label: "开始处理", onClick: () => onTransition(item, "IN_PROGRESS") }; + } if (item.status === "IN_PROGRESS") return { key: "resolve", label: "标记解决", onClick: () => onTransition(item, "RESOLVED") }; if (item.status === "RESOLVED") return { key: "close", label: "关闭", onClick: () => onTransition(item, "CLOSED") }; return null; @@ -60,7 +62,7 @@ export function WorkOrdersView({ user, workorders, equipment, technicians, filte

{canAssign ? "工单队列" : "维修工单"}

{canAssign - ? "管理员可派给任意维修员;维修员可主动接单" + ? "管理员可派给任意维修员;维修员可主动接单;差账单由管理员直接处理" : "可接未分配工单,并处理分配给你的工单"} · 共 {workorders?.totalElements ?? 0} 条 @@ -72,7 +74,7 @@ export function WorkOrdersView({ user, workorders, equipment, technicians, filte const primary = primaryStep(item); const menu = menuEntries( canActOn(item) && item.status === "RESOLVED" && { key: "reopen", label: "重新打开", onClick: () => onTransition(item, "IN_PROGRESS") }, - canAssign && (item.status === "SUBMITTED" || item.status === "ASSIGNED") + canAssign && (item.status === "SUBMITTED" || (item.category === "FAULT" ? item.status === "ASSIGNED" : item.status === "IN_PROGRESS")) && { key: "cancel", label: "取消工单", danger: true, onClick: () => onTransition(item, "CANCELLED") }, ); return @@ -81,11 +83,11 @@ export function WorkOrdersView({ user, workorders, equipment, technicians, filte {item.reporterName} · {item.description} - {technicianName(item.assigneeId)} - + {item.category === "PAYMENT_DISCREPANCY" ? "管理员处理" : technicianName(item.assigneeId)} + {formatDate(item.updatedAt)}
- {canAssign && item.status === "SUBMITTED" &&
+ {canAssign && item.category === "FAULT" && item.status === "SUBMITTED" &&
diff --git a/frontend/app/lib/api.ts b/frontend/app/lib/api.ts index 1475299..a7c7312 100644 --- a/frontend/app/lib/api.ts +++ b/frontend/app/lib/api.ts @@ -152,12 +152,14 @@ async function performRefresh(generation: number, signal: AbortSignal): Promise< cache: "no-store", signal, }); - if (response.status === 204 || !response.ok) { + if (response.status === 204 || response.status === 401) { if (generation === authGeneration) { clearAuthSession(); } return null; } + // Proxy failures and other HTTP errors do not establish that the session expired. + if (!response.ok) return null; const session = await response.json() as AuthSession; if (generation !== authGeneration) return null; setAuthSession(session); diff --git a/frontend/app/lib/types.ts b/frontend/app/lib/types.ts index 4283b1c..862cf6b 100644 --- a/frontend/app/lib/types.ts +++ b/frontend/app/lib/types.ts @@ -9,7 +9,7 @@ export type ReservationConflict = { id: number; startTime: string; endTime: stri export type Reservation = { id: number; equipmentId: number; requesterId: number; requesterName: string; purpose: string; startTime: string; endTime: string; status: string; createdAt: string; expiresAt: string; /** Only on PENDING rows from the list endpoint: non-empty means approving now would be rejected. */ conflictsWith?: ReservationConflict[] }; -export type WorkOrder = { id: number; equipmentId: number; reporterId: number; reporterName: string; title: string; description: string; priority: string; status: string; assigneeId: number | null; createdAt: string; updatedAt: string; resolvedAt: string | null }; +export type WorkOrder = { id: number; equipmentId: number; reporterId: number; reporterName: string; title: string; description: string; category: "FAULT" | "PAYMENT_DISCREPANCY"; priority: string; status: string; assigneeId: number | null; createdAt: string; updatedAt: string; resolvedAt: string | null }; export type AuditLog = { id: number; actorUsername: string; actorRole: string; action: string; targetType: string; targetId: number | null; details: string; createdAt: string }; export type Stats = { usersTotal: number; equipmentTotal: number; equipmentAvailable: number; equipmentMaintenance: number; equipmentInUse: number; equipmentRetired: number; reservationsPending: number; reservationsApproved: number; reservationsToday: number; workOrdersOpen: number; workOrdersUrgent: number; paymentDiscrepanciesOpen: number }; /** Daily counts from /api/dashboard/trends, oldest first; the last day is today. */ diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e7f0323..8be6a92 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -245,8 +245,8 @@ export default function Home() { }, [accessToken, reservationFilter, workorderFilter, notify, loadTrends]); // Keep React accessToken in sync when api() silently refreshes the HttpOnly-cookie session. - // This is the single place that reacts to a session change, so every path that ends a - // session (explicit logout, failed refresh, a different user signing in) clears the same state. + // This is the single place that resets user-scoped state on logout or a principal change. + // Refresh only clears the session on 204 (no refresh cookie) or 401 (invalid token). useEffect(() => { return subscribeSession((session) => { if (!session) { diff --git a/frontend/tests/table-parts.test.mjs b/frontend/tests/table-parts.test.mjs index 625c1a5..55328f5 100644 --- a/frontend/tests/table-parts.test.mjs +++ b/frontend/tests/table-parts.test.mjs @@ -45,7 +45,7 @@ const devices = [ const technicians = [{ id: 7, username: "tech", displayName: "王师傅" }, { id: 8, username: "tech2", displayName: "李师傅" }]; const order = (id, status, extra = {}) => ({ id, equipmentId: 1, reporterId: 3, reporterName: "林知夏", title: `故障 ${id}`, description: "无法开机", - priority: "HIGH", status, assigneeId: null, createdAt: "2026-10-19T09:00:00+08:00", updatedAt: "2026-10-19T10:00:00+08:00", resolvedAt: null, ...extra, + category: "FAULT", priority: "HIGH", status, assigneeId: null, createdAt: "2026-10-19T09:00:00+08:00", updatedAt: "2026-10-19T10:00:00+08:00", resolvedAt: null, ...extra, }); const noop = () => {}; const rowOf = (html, attr, id) => html.match(new RegExp(`[\\s\\S]*?`))[0]; @@ -88,12 +88,86 @@ test("work orders show one forward step per row, by role and status", () => { assert.ok(rowOf(own, "data-workorder-id", 2).includes(">测试<") && !own.includes("维修员 #7")); const admin = renderOrders("ADMIN", orders); + assert.ok(rowOf(admin, "data-workorder-id", 1).includes('class="state state-warn">待派单')); assert.ok(rowOf(admin, "data-workorder-id", 1).includes('data-action="assign"') && rowOf(admin, "data-workorder-id", 1).includes("王师傅")); assert.ok(!rowOf(admin, "data-workorder-id", 1).includes('data-action="claim"')); assert.ok(rowOf(admin, "data-workorder-id", 5).includes('data-action="start"'), "admins act on any order"); assert.ok(rowOf(admin, "data-workorder-id", 5).includes('data-action="more"'), "cancel lives in the menu"); }); +test("payment discrepancies offer admin processing without assignment or technician actions", () => { + const orders = ["SUBMITTED", "IN_PROGRESS", "RESOLVED", "CLOSED", "CANCELLED"].map((status, index) => + order(index + 1, status, { category: "PAYMENT_DISCREPANCY" })); + const admin = renderOrders("ADMIN", orders, { technicians: [] }); + assert.ok(rowOf(admin, "data-workorder-id", 1).includes('class="state state-warn">待处理')); + assert.ok(admin.includes("差账单由管理员直接处理")); + assert.ok(rowOf(admin, "data-workorder-id", 1).includes('data-action="start"')); + assert.ok(rowOf(admin, "data-workorder-id", 2).includes('data-action="resolve"')); + assert.ok(rowOf(admin, "data-workorder-id", 2).includes('data-action="more"'), "processing discrepancies can be cancelled"); + assert.ok(rowOf(admin, "data-workorder-id", 3).includes('data-action="close"')); + assert.ok(rowOf(admin, "data-workorder-id", 3).includes('data-action="more"'), "resolved discrepancies can be reopened"); + for (const id of [4, 5]) assert.ok(!rowOf(admin, "data-workorder-id", id).includes(" { + const { container, root } = mountDom(t); + let listener; + let status = "SUBMITTED"; + const transitions = []; + const { default: Home } = loadComponent("page.tsx", { + subscribeSession: (next) => { listener = next; return noop; }, + clearLegacyAuthStorage: noop, + refreshSession: async () => listener({ accessToken: "t", user: { id: 1, username: "admin", displayName: "管理员", role: "ADMIN" } }), + api: async (path, _token, init) => { + const url = new URL(path, "http://localhost/"); + switch (url.pathname) { + case "/api/dashboard/stats": return {}; + case "/api/dashboard/trends": return null; + case "/api/equipment": return page(devices); + case "/api/users/technicians": return []; + case "/api/reservations": return page([]); + case "/api/work-orders": return page([order(42, status, { category: "PAYMENT_DISCREPANCY" })]); + case "/api/work-orders/42/status": { + assert.equal(init.method, "PATCH"); + const body = JSON.parse(init.body); + assert.deepEqual(Object.keys(body), ["targetStatus"]); + transitions.push(body.targetStatus); + status = body.targetStatus; + return {}; + } + default: throw new Error(`Unexpected API request: ${path}`); + } + }, + }); + await act(async () => root.render(React.createElement(Home))); + await waitFor(() => container.querySelector('[data-nav="workorders"]')); + await act(async () => container.querySelector('[data-nav="workorders"]').click()); + for (const [action, target, menu] of [ + ["start", "IN_PROGRESS"], ["resolve", "RESOLVED"], ["reopen", "IN_PROGRESS", true], + ["resolve", "RESOLVED"], ["close", "CLOSED"], + ]) { + await waitFor(() => container.querySelector(`[data-action="${menu ? "more" : action}"]`)); + if (menu) await act(async () => container.querySelector('[data-action="more"]').click()); + await act(async () => document.querySelector(`[data-${menu ? "menu" : "action"}="${action}"]`).click()); + await waitFor(() => container.querySelector('[data-workorder-id="42"] .state')?.textContent === ({ IN_PROGRESS: "处理中", RESOLVED: "已解决", CLOSED: "已关闭" })[target]); + } + for (const from of ["SUBMITTED", "IN_PROGRESS"]) { + status = from; + await act(async () => container.querySelector(".toolbar .secondary").click()); + await waitFor(() => container.querySelector('[data-action="more"]')); + await act(async () => container.querySelector('[data-action="more"]').click()); + await act(async () => document.querySelector('[data-menu="cancel"]').click()); + await waitFor(() => container.querySelector('[data-workorder-id="42"] .state')?.textContent === "已取消"); + } + assert.deepEqual(transitions, ["IN_PROGRESS", "RESOLVED", "IN_PROGRESS", "RESOLVED", "CLOSED", "CANCELLED", "CANCELLED"]); +}); + test("work orders name the device from the lookup and fall back to its id", () => { const html = renderOrders("ADMIN", [order(1, "CLOSED"), order(2, "CLOSED", { equipmentId: 99 })]); assert.ok(rowOf(html, "data-workorder-id", 1).includes("扫描电镜") && rowOf(html, "data-workorder-id", 1).includes("SEM-201")); diff --git a/frontend/tests/token-sync.test.mjs b/frontend/tests/token-sync.test.mjs index 849710a..ae5da89 100644 --- a/frontend/tests/token-sync.test.mjs +++ b/frontend/tests/token-sync.test.mjs @@ -194,6 +194,72 @@ test("a network error during refresh does not clear an existing session", async } }); +for (const status of [400, 403, 429, 500, 502, 503, 504, 204, 401]) { + test(`refresh HTTP ${status} only ends the session when authentication is absent or invalid`, async () => { + const api = await loadAuthModule(); + const events = []; + const originalFetch = globalThis.fetch; + api.subscribeSession((session) => events.push(session?.accessToken ?? null)); + const session = { + accessToken: "keep-me", expiresIn: 900, + user: { id: 1, username: "student", displayName: "Student", role: "STUDENT" }, + }; + api.setAuthSession(session); + globalThis.fetch = async () => new Response(null, { status }); + try { + assert.equal(await api.refreshSession(), null); + const invalid = status === 204 || status === 401; + assert.deepEqual(events, invalid ? ["keep-me", null] : ["keep-me"]); + if (!invalid) { + globalThis.fetch = async () => Response.json({ ...session, accessToken: "recovered" }); + assert.equal((await api.refreshSession())?.accessToken, "recovered"); + assert.deepEqual(events, ["keep-me", "recovered"]); + } + } finally { + globalThis.fetch = originalFetch; + } + }); +} + +test("an API 401 followed by a BFF 502 reports failure without logging out and recovers on retry", async () => { + const api = await loadAuthModule(); + const proxy = await loadProxyModule(); + const originalFetch = globalThis.fetch; + const events = []; + const session = { + accessToken: "old-token", expiresIn: 900, + user: { id: 1, username: "student", displayName: "Student", role: "STUDENT" }, + }; + api.subscribeSession((value) => events.push(value?.accessToken ?? null)); + api.setAuthSession(session); + let available = false; + globalThis.fetch = async (url, init) => { + if (url instanceof URL) { + if (!available) throw new TypeError("backend down"); + return Response.json({ ...session, accessToken: "new-token" }); + } + if (url.endsWith("/auth/refresh")) { + const response = await proxy.POST(new Request(`http://localhost${url}`, init), { + params: Promise.resolve({ path: ["api", "auth", "refresh"] }), + }); + assert.equal(response.status, available ? 200 : 502); + return response; + } + return init.headers.get("Authorization") === "Bearer new-token" + ? Response.json({ ok: true }) + : Response.json({ message: "access token expired" }, { status: 401 }); + }; + try { + await assert.rejects(api.api("/api/equipment", "old-token"), /access token expired/); + assert.deepEqual(events, ["old-token"]); + available = true; + assert.deepEqual(await api.api("/api/equipment", "old-token"), { ok: true }); + assert.deepEqual(events, ["old-token", "new-token"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("logout waits for an already-started refresh before clearing the cookie", async () => { const api = await loadAuthModule(); const refreshResponse = deferred();