mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 11:50:44 +08:00
feat(web): NetX workbench, UME-only UI, i18n, and header status
- Replace sidebar app with workbench home and module tabs (openOrFocusModule) - Remove legacy pages; add module registry, shared tab channel, WEB.md - Add zh/en i18n, global ToastProvider, restore oclaw bridge pill in header Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
664775927b
commit
282dbe75af
33 changed files with 1631 additions and 1677 deletions
16
web/src/utils/display.ts
Normal file
16
web/src/utils/display.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** Map legacy API labels to English for UI display. */
|
||||
|
||||
const LEGACY_INTERVAL_LABEL_EN: Record<string, string> = {
|
||||
未启用: "disabled",
|
||||
实时: "realtime",
|
||||
};
|
||||
|
||||
export function runtimeIntervalLabel(label?: string | null): string {
|
||||
const raw = String(label ?? "").trim();
|
||||
if (!raw) return "—";
|
||||
return LEGACY_INTERVAL_LABEL_EN[raw] ?? raw;
|
||||
}
|
||||
|
||||
export function pageCount(total: number, pageSize: number): number {
|
||||
return Math.max(1, Math.ceil(Math.max(0, total) / Math.max(1, pageSize)));
|
||||
}
|
||||
68
web/src/utils/moduleWindows.ts
Normal file
68
web/src/utils/moduleWindows.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { getModuleById } from "../config/modules";
|
||||
import {
|
||||
broadcastMessage,
|
||||
createFocusRequestId,
|
||||
focusWindowSafe,
|
||||
registerBroadcastListener,
|
||||
waitForFocusAck,
|
||||
} from "./tabChannel";
|
||||
|
||||
export type ModuleWindowSpec = {
|
||||
moduleId: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const MODULE_CHANNEL = "netx-module-nav";
|
||||
|
||||
type FocusModuleMessage = {
|
||||
type: "focus-module";
|
||||
moduleId: string;
|
||||
path: string;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export function moduleWindowName(moduleId: string): string {
|
||||
return `netx-module-${moduleId}`;
|
||||
}
|
||||
|
||||
function moduleAckKey(moduleId: string): string {
|
||||
return `netx-mod-focus-ack-${moduleId}`;
|
||||
}
|
||||
|
||||
export function registerModuleWindow(moduleId: string): () => void {
|
||||
try {
|
||||
window.name = moduleWindowName(moduleId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return registerBroadcastListener<FocusModuleMessage>(MODULE_CHANNEL, moduleAckKey(moduleId), (data) => {
|
||||
const targetPath = String(data.path || "").trim();
|
||||
if (targetPath && window.location.pathname !== targetPath) {
|
||||
window.location.assign(targetPath);
|
||||
}
|
||||
window.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export function openOrFocusModule({ moduleId, path }: ModuleWindowSpec): void {
|
||||
const mod = getModuleById(moduleId);
|
||||
const targetPath = mod?.path ?? path;
|
||||
const requestId = createFocusRequestId(`mod-${moduleId}`);
|
||||
const name = moduleWindowName(moduleId);
|
||||
|
||||
broadcastMessage<FocusModuleMessage>(MODULE_CHANNEL, {
|
||||
type: "focus-module",
|
||||
moduleId,
|
||||
path: targetPath,
|
||||
requestId,
|
||||
});
|
||||
|
||||
focusWindowSafe(window.open(targetPath, name));
|
||||
|
||||
waitForFocusAck(moduleAckKey(moduleId), requestId, () => {
|
||||
focusWindowSafe(window.open(targetPath, name));
|
||||
});
|
||||
}
|
||||
|
||||
export { moduleIdFromPath } from "../config/modules";
|
||||
64
web/src/utils/tabChannel.ts
Normal file
64
web/src/utils/tabChannel.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/** Shared cross-tab focus helpers (BroadcastChannel + sessionStorage ack). */
|
||||
|
||||
export const TAB_FOCUS_DELAY_MS = 200;
|
||||
|
||||
export function createFocusRequestId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function registerBroadcastListener<T extends { requestId: string }>(
|
||||
channelName: string,
|
||||
ackStorageKey: string,
|
||||
onMessage: (data: T) => void,
|
||||
): () => void {
|
||||
if (typeof BroadcastChannel === "undefined") {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const channel = new BroadcastChannel(channelName);
|
||||
channel.onmessage = (ev: MessageEvent<T>) => {
|
||||
const data = ev.data;
|
||||
if (!data?.requestId) return;
|
||||
try {
|
||||
sessionStorage.setItem(ackStorageKey, data.requestId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onMessage(data);
|
||||
};
|
||||
return () => channel.close();
|
||||
}
|
||||
|
||||
export function broadcastMessage<T>(channelName: string, message: T): void {
|
||||
if (typeof BroadcastChannel === "undefined") return;
|
||||
const channel = new BroadcastChannel(channelName);
|
||||
channel.postMessage(message);
|
||||
channel.close();
|
||||
}
|
||||
|
||||
export function waitForFocusAck(
|
||||
ackStorageKey: string,
|
||||
requestId: string,
|
||||
fallback: () => void,
|
||||
delayMs: number = TAB_FOCUS_DELAY_MS,
|
||||
): void {
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem(ackStorageKey) === requestId) {
|
||||
sessionStorage.removeItem(ackStorageKey);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fallback();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
export function focusWindowSafe(win: Window | null): void {
|
||||
try {
|
||||
win?.focus();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
50
web/src/utils/workbench.ts
Normal file
50
web/src/utils/workbench.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
broadcastMessage,
|
||||
createFocusRequestId,
|
||||
focusWindowSafe,
|
||||
registerBroadcastListener,
|
||||
waitForFocusAck,
|
||||
} from "./tabChannel";
|
||||
|
||||
export const WORKBENCH_WINDOW_NAME = "netx-workbench";
|
||||
|
||||
const CHANNEL_NAME = "netx-workbench-nav";
|
||||
const FOCUS_ACK_KEY = "netx-wb-focus-ack";
|
||||
|
||||
type FocusWorkbenchMessage = {
|
||||
type: "focus-workbench";
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export function registerWorkbenchWindow(): () => void {
|
||||
try {
|
||||
window.name = WORKBENCH_WINDOW_NAME;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return registerBroadcastListener<FocusWorkbenchMessage>(CHANNEL_NAME, FOCUS_ACK_KEY, () => {
|
||||
window.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export function returnToWorkbench(): void {
|
||||
const requestId = createFocusRequestId("wb");
|
||||
|
||||
broadcastMessage<FocusWorkbenchMessage>(CHANNEL_NAME, {
|
||||
type: "focus-workbench",
|
||||
requestId,
|
||||
});
|
||||
|
||||
try {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.focus();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
waitForFocusAck(FOCUS_ACK_KEY, requestId, () => {
|
||||
focusWindowSafe(window.open("/", WORKBENCH_WINDOW_NAME));
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue