feat(workbench): add manual task refresh

This commit is contained in:
2026-09-24 12:45:43 +08:00
parent ddf953b641
commit 0a53e665eb
5 changed files with 84 additions and 5 deletions
@@ -5,6 +5,7 @@ import {
ChevronRight, ChevronRight,
ClipboardList, ClipboardList,
History, History,
RefreshCw,
X, X,
XCircle XCircle
} from "lucide-react"; } from "lucide-react";
@@ -100,6 +101,7 @@ type ScheduledConditionFeedProps = {
onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>; onTaskReconcile?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>; onLoadTaskEvidenceSnapshot?: (taskId: string, evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
onTaskRefresh?: () => void; onTaskRefresh?: () => void;
onRefresh?: () => void | Promise<void>;
onCreateSchedule?: (input: ScheduleCreateRequest) => Promise<unknown>; onCreateSchedule?: (input: ScheduleCreateRequest) => Promise<unknown>;
}; };
@@ -133,6 +135,7 @@ export function ScheduledConditionFeed({
onTaskReconcile, onTaskReconcile,
onLoadTaskEvidenceSnapshot, onLoadTaskEvidenceSnapshot,
onTaskRefresh, onTaskRefresh,
onRefresh,
onCreateSchedule onCreateSchedule
}: ScheduledConditionFeedProps) { }: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false); const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
@@ -152,6 +155,7 @@ export function ScheduledConditionFeed({
const [scheduleNextRunAt, setScheduleNextRunAt] = useState(() => formatLocalDateTimeInput(new Date(Date.now() + 15 * 60_000))); const [scheduleNextRunAt, setScheduleNextRunAt] = useState(() => formatLocalDateTimeInput(new Date(Date.now() + 15 * 60_000)));
const [scheduleSubmitting, setScheduleSubmitting] = useState(false); const [scheduleSubmitting, setScheduleSubmitting] = useState(false);
const [scheduleError, setScheduleError] = useState<string | null>(null); const [scheduleError, setScheduleError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const mobileSheet = presentation === "mobile-sheet"; const mobileSheet = presentation === "mobile-sheet";
const desktopDock = presentation === "desktop-dock"; const desktopDock = presentation === "desktop-dock";
const compactPresentation = mobileSheet || desktopDock; const compactPresentation = mobileSheet || desktopDock;
@@ -330,6 +334,23 @@ export function ScheduledConditionFeed({
} finally { setScheduleSubmitting(false); } } finally { setScheduleSubmitting(false); }
} }
async function handleRefresh() {
if (!onRefresh || refreshing) return;
setRefreshing(true);
try {
await onRefresh();
} catch (error) {
showMapNotice({
id: "scheduled-condition-refresh",
tone: "error",
title: "工况任务刷新失败",
message: error instanceof Error ? error.message : "暂时无法重新获取工况任务。"
});
} finally {
setRefreshing(false);
}
}
function handleSelectTimelineCondition(conditionId: string) { function handleSelectTimelineCondition(conditionId: string) {
updateSelectedConditionId(conditionId); updateSelectedConditionId(conditionId);
if (!expanded) { if (!expanded) {
@@ -449,6 +470,22 @@ export function ScheduledConditionFeed({
<CalendarPlus size={17} aria-hidden="true" /> <CalendarPlus size={17} aria-hidden="true" />
</button> </button>
) : null} ) : null}
{onRefresh ? (
<button
type="button"
aria-label={refreshing ? "正在刷新工况任务" : "刷新工况任务"}
title={refreshing ? "正在刷新" : "刷新工况任务"}
disabled={refreshing}
onClick={() => void handleRefresh()}
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors active:scale-95 disabled:cursor-wait disabled:text-blue-600 [@media(hover:hover)]:hover:bg-blue-50 [@media(hover:hover)]:hover:text-blue-700"
>
<RefreshCw
size={17}
aria-hidden="true"
className={cn(refreshing && "animate-spin motion-reduce:animate-none")}
/>
</button>
) : null}
<button <button
type="button" type="button"
aria-expanded={expanded} aria-expanded={expanded}
@@ -20,6 +20,7 @@ export type WorkbenchSyncState = {
status: "connecting" | "live" | "reconnecting"; status: "connecting" | "live" | "reconnecting";
error: string | null; error: string | null;
acknowledgeEventsThrough: (eventSequence: number) => void; acknowledgeEventsThrough: (eventSequence: number) => void;
refresh: () => Promise<void>;
}; };
export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState { export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): WorkbenchSyncState {
@@ -30,9 +31,15 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
const cursorRef = useRef(0); const cursorRef = useRef(0);
const statusRef = useRef<WorkbenchSyncState["status"]>("connecting"); const statusRef = useRef<WorkbenchSyncState["status"]>("connecting");
const runtimeRefreshRef = useRef<number | undefined>(undefined); const runtimeRefreshRef = useRef<number | undefined>(undefined);
const refreshSnapshotRef = useRef<(() => Promise<void>) | null>(null);
const acknowledgeEventsThrough = useCallback((eventSequence: number) => { const acknowledgeEventsThrough = useCallback((eventSequence: number) => {
setEvents((current) => current.filter((event) => event.event_seq > eventSequence)); setEvents((current) => current.filter((event) => event.event_seq > eventSequence));
}, []); }, []);
const refresh = useCallback(async () => {
const refreshSnapshot = refreshSnapshotRef.current;
if (!refreshSnapshot) throw new Error("工作台同步尚未就绪");
await refreshSnapshot();
}, []);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -49,6 +56,7 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
setError(null); setError(null);
initialized = true; initialized = true;
}; };
refreshSnapshotRef.current = loadSnapshot;
const connect = async () => { const connect = async () => {
try { try {
@@ -101,11 +109,12 @@ export function useWorkbenchSync(getAccessToken?: AccessTokenProvider): Workbenc
}, RECONCILE_INTERVAL_MS); }, RECONCILE_INTERVAL_MS);
return () => { return () => {
controller.abort(); controller.abort();
refreshSnapshotRef.current = null;
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
if (reconcileTimer !== undefined) window.clearInterval(reconcileTimer); if (reconcileTimer !== undefined) window.clearInterval(reconcileTimer);
if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current); if (runtimeRefreshRef.current !== undefined) window.clearTimeout(runtimeRefreshRef.current);
}; };
}, [getAccessToken]); }, [getAccessToken]);
return { snapshot, events, status, error, acknowledgeEventsThrough }; return { snapshot, events, status, error, acknowledgeEventsThrough, refresh };
} }
@@ -1336,6 +1336,7 @@ export function MapWorkbenchPage({
onTaskReconcile={operationalTasks.reconcileAction} onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot} onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh} onTaskRefresh={operationalTasks.refresh}
onRefresh={workbenchSync.refresh}
onCreateSchedule={handleCreateMockScheduledTask} onCreateSchedule={handleCreateMockScheduledTask}
/> />
</div> </div>
@@ -1483,6 +1484,7 @@ export function MapWorkbenchPage({
onTaskReconcile={operationalTasks.reconcileAction} onTaskReconcile={operationalTasks.reconcileAction}
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot} onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
onTaskRefresh={operationalTasks.refresh} onTaskRefresh={operationalTasks.refresh}
onRefresh={workbenchSync.refresh}
onCreateSchedule={handleCreateMockScheduledTask} onCreateSchedule={handleCreateMockScheduledTask}
/> />
)} )}
@@ -125,6 +125,31 @@ test.describe("mobile workbench sheet", () => {
await expect(page.getByRole("button", { name: "打开工况任务" })).toBeVisible(); await expect(page.getByRole("button", { name: "打开工况任务" })).toBeVisible();
}); });
test("keeps refresh to the right of schedule on a 320px viewport", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
const scheduleButton = sheet.getByRole("button", { name: "配置定时流程" });
const refreshButton = sheet.getByRole("button", { name: "刷新工况任务" });
await expect(scheduleButton).toBeVisible();
await expect(refreshButton).toBeVisible();
const [sheetBox, scheduleBox, refreshBox] = await Promise.all([
sheet.boundingBox(),
scheduleButton.boundingBox(),
refreshButton.boundingBox()
]);
expect(sheetBox).not.toBeNull();
expect(scheduleBox).not.toBeNull();
expect(refreshBox).not.toBeNull();
expect(refreshBox!.x).toBeGreaterThan(scheduleBox!.x);
expect(refreshBox!.x + refreshBox!.width).toBeLessThanOrEqual(
sheetBox!.x + sheetBox!.width
);
});
test("keeps condition detail expanded inside the mobile sheet", async ({ page }) => { test("keeps condition detail expanded inside the mobile sheet", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "打开工况任务" }).click(); await page.getByRole("button", { name: "打开工况任务" }).click();
+10 -4
View File
@@ -197,13 +197,15 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
alert_availability: "not_configured", generated_at: "2026-09-23T08:30:00Z", alert_availability: "not_configured", generated_at: "2026-09-23T08:30:00Z",
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
}; };
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => let snapshotRequests = 0;
route.fulfill({ json: { await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => {
snapshotRequests += 1;
return route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime: runtimeSnapshot, schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime: runtimeSnapshot,
tasks: [summary(task), summary(appointmentTask)], notifications: [], tasks: [summary(task), summary(appointmentTask)], notifications: [],
generated_at: "2026-09-23T08:30:00Z" generated_at: "2026-09-23T08:30:00Z"
} }) } });
); });
await page.route("http://server.test/api/v1/workbench/events**", (route) => await page.route("http://server.test/api/v1/workbench/events**", (route) =>
route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" }) route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" })
); );
@@ -267,6 +269,10 @@ test("审批动作展示中文参数和证据,viewer 不可审批,冲突后
await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据"); await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据");
await expect(conditionPanel.getByRole("button", { name: /调查任务/ })).toContainText("后端"); await expect(conditionPanel.getByRole("button", { name: /调查任务/ })).toContainText("后端");
await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("后端"); await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("后端");
const conditionRefreshButton = conditionPanel.getByRole("button", { name: "刷新工况任务" });
await expect(conditionRefreshButton).toBeVisible();
await conditionRefreshButton.click();
await expect.poll(() => snapshotRequests).toBe(2);
await conditionPanel.getByRole("button", { name: "配置定时流程" }).click(); await conditionPanel.getByRole("button", { name: "配置定时流程" }).click();
await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检"); await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检");
await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。"); await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。");