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
4 changes: 2 additions & 2 deletions frontend/app/components/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export const STATE_TONES: Record<string, StateTone> = {
SUBMITTED: "warn", ASSIGNED: "info", IN_PROGRESS: "info", RESOLVED: "ok", CLOSED: "idle",
};

export function StateBadge({ status }: { status: string }) {
return <span className={`state state-${STATE_TONES[status] ?? "idle"}`}><i />{statusLabels[status] ?? status}</span>;
export function StateBadge({ status, label = statusLabels[status] ?? status }: { status: string; label?: string }) {
return <span className={`state state-${STATE_TONES[status] ?? "idle"}`}><i />{label}</span>;
}

export function CategoryTag({ category }: { category: string }) {
Expand Down
18 changes: 10 additions & 8 deletions frontend/app/components/views/WorkOrdersView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -60,7 +62,7 @@ export function WorkOrdersView({ user, workorders, equipment, technicians, filte
<h3>{canAssign ? "工单队列" : "维修工单"}</h3>
<small>
{canAssign
? "管理员可派给任意维修员;维修员可主动接单"
? "管理员可派给任意维修员;维修员可主动接单;差账单由管理员直接处理"
: "可接未分配工单,并处理分配给你的工单"}
· 共 {workorders?.totalElements ?? 0} 条
</small>
Expand All @@ -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 <tr key={item.id} data-workorder-id={item.id}>
Expand All @@ -81,11 +83,11 @@ export function WorkOrdersView({ user, workorders, equipment, technicians, filte
<small>{item.reporterName} · {item.description}</small>
</div></td>
<td><EquipmentCell equipment={equipmentById.get(item.equipmentId)} equipmentId={item.equipmentId} /></td>
<td className={item.assigneeId ? "" : "muted"}>{technicianName(item.assigneeId)}</td>
<td><StateBadge status={item.status} /></td>
<td className={item.assigneeId ? "" : "muted"}>{item.category === "PAYMENT_DISCREPANCY" ? "管理员处理" : technicianName(item.assigneeId)}</td>
<td><StateBadge status={item.status} label={item.category === "PAYMENT_DISCREPANCY" && item.status === "SUBMITTED" ? "待处理" : undefined} /></td>
<td className="nowrap">{formatDate(item.updatedAt)}</td>
<td><div className="list-actions">
{canAssign && item.status === "SUBMITTED" && <div className="assign-control">
{canAssign && item.category === "FAULT" && item.status === "SUBMITTED" && <div className="assign-control">
<select aria-label={`为工单 ${item.id} 选择维修员`} value={selected} onChange={(e) => setSelectedAssignees({ ...selectedAssignees, [item.id]: e.target.value })}>
{technicians.map((technician) => <option key={technician.id} value={technician.id}>{technician.displayName}</option>)}
</select>
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type Reservation = { id: number; equipmentId: number; requesterId: number
/** Only on PENDING rows from the list endpoint: non-empty means approving now would be rejected. */
conflictsWith?: ReservationConflict[] };
export type PaymentOrder = { orderNo: string; reservationId: number; amountCents: number; paidCents: number; refundedCents: number; status: string };
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. */
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,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) {
Expand Down
76 changes: 75 additions & 1 deletion frontend/tests/table-parts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<tr ${attr}="${id}">[\\s\\S]*?</tr>`))[0];
Expand Down Expand Up @@ -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"><i></i>待派单</span>'));
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"><i></i>待处理</span>'));
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("<button"));
assert.ok(!admin.includes('data-action="assign"') && !admin.includes("选择维修员"));
assert.ok(!rowOf(admin, "data-workorder-id", 1).includes("待分派"));
for (const role of ["TECHNICIAN", "TEACHER", "STUDENT"]) {
const html = renderOrders(role, orders);
for (const item of orders) assert.ok(!rowOf(html, "data-workorder-id", item.id).includes("<button"));
}
});

test("an admin can advance, reopen and cancel discrepancies through the page without an assignee", async (t) => {
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"));
Expand Down
66 changes: 66 additions & 0 deletions frontend/tests/token-sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading