页面嵌套修改

This commit is contained in:
董宏凯 committed 2026-07-29 21:53:32 +08:00
1 parent 213dd841b5
commit 4450f342d8
38 files changed
+2020 -309

No files matched your search

+36 -7
View File
@@ -5,11 +5,25 @@ import { readRuntimeEnv } from "@/env";
*
* Add a new entry here when an external system needs a short ``routeKey``.
* Targets must remain internal SPA routes; LoginPage validates them again
* before navigating.
* before navigating. The optional display settings are converted into the
* existing route parameters on the destination URL.
*/
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, string>> = {
a: "/page/canvas/ai-writing",
"8bf": "/page/strategy/qa/8bf-roundtable",
export interface RouteKeyMapping {
/** Internal SPA route to open after sign-in. */
path: string;
/** Add `hideChrome=1` to hide the global header and page sidebar. */
hideChrome?: boolean;
/** Add the existing `theme` route parameter and apply it during sign-in. */
theme?: string;
}
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, RouteKeyMapping>> = {
a: { path: "/page/canvas/ai-writing" },
"8bf": {
path: "/page/strategy/qa/8bf-roundtable",
hideChrome: true,
theme: "dark-blue",
},
};
/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */
@@ -26,8 +40,23 @@ export function getRouteKeyLoginPassword(): string {
return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? "";
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
export function resolveRouteKeyMapping(
routeKey: string | null | undefined,
): RouteKeyMapping | undefined {
const key = routeKey?.trim();
if (!key) return "";
return ROUTE_KEY_MAPPINGS[key] ?? "";
if (!key) return undefined;
return ROUTE_KEY_MAPPINGS[key];
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
const mapping = resolveRouteKeyMapping(routeKey);
if (!mapping) return "";
const [path, ownQuery = ""] = mapping.path.split("?");
const query = new URLSearchParams(ownQuery);
if (mapping.hideChrome) query.set("hideChrome", "1");
if (mapping.theme) query.set("theme", mapping.theme);
const serializedQuery = query.toString();
return serializedQuery ? `${path}?${serializedQuery}` : path;
}
@@ -0,0 +1,24 @@
/**
* Whether the application is currently hosted by another page.
*
* This intentionally covers both a native `<iframe>` and Wujie's child-app
* runtime. Accessing `window.top` is safe across origins for identity
* comparison, but the guard keeps the login/route bootstrap resilient to
* restrictive browser hosts.
*/
export function isEmbeddedHost(): boolean {
if (typeof window === "undefined") return false;
const hostWindow = window as Window & {
__POWERED_BY_WUJIE__?: boolean;
};
if (hostWindow.__POWERED_BY_WUJIE__) return true;
try {
return window.self !== window.top;
} catch {
// Some sandboxed or cross-origin hosts deny top-window access. They are
// still embedded contexts and should use the same safe entry route.
return true;
}
}
@@ -2,13 +2,18 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
buildStandardChatRedirectPath,
buildEmbeddedIframeRedirectPath,
buildChatsIframeRedirectPath,
buildChatsIframeThreadPath,
hasIframePresentationOverride,
isExplicitBrainyIframeMode,
isChatsIframeWorkspacePath,
resolveChatsIframeEntryRedirect,
resolvePostLoginRedirectPath,
resolveForcedThemeForPath,
resolveStandardChatFallback,
shouldUseChatsIframeProtocol,
} from "./chats-iframe-route.ts";
test("matches only workspace chats_iframe routes", () => {
@@ -37,7 +42,7 @@ test("forces light theme for landing and chats_iframe routes only", () => {
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
});
test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => {
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
assert.equal(isExplicitBrainyIframeMode(""), false);
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false);
@@ -45,34 +50,72 @@ test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => {
assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true);
});
test("upgrades legacy iframe-source routes to the iframe protocol", () => {
test("keeps bare from=iframe on the original dedicated iframe protocol", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe",
),
null,
);
assert.equal(shouldUseChatsIframeProtocol("?from=iframe"), true);
assert.equal(hasIframePresentationOverride("?from=iframe"), false);
});
test("uses the dedicated AgentSelector route for a bare embedded host", () => {
assert.equal(shouldUseChatsIframeProtocol("", true), true);
assert.equal(
buildEmbeddedIframeRedirectPath("?ticket=secret&theme=dark-blue"),
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"",
true,
),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/thread-123",
"",
true,
),
"/page/workspace/chats_iframe/thread-123?from=iframe",
);
assert.equal(shouldUseChatsIframeProtocol("?chatMode=full", true), false);
assert.equal(shouldUseChatsIframeProtocol("?hideChrome=1", true), false);
assert.equal(
buildStandardChatRedirectPath("?hideChrome=1"),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
});
test("routes legacy iframe links with standard presentation flags to full chat", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&hideHeader=1",
),
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
);
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&hideChrome=1",
),
"/page/workspace/chats_iframe/new?from=iframe&hideChrome=1&chatMode=iframe",
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
});
test("honors an explicit chat mode over the legacy iframe source", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&chatMode=full",
"?from=iframe&embed=1&theme=dark-blue",
),
"/page/workspace/chats/new?chatMode=full",
);
});
test("treats a blank chat mode as absent for the legacy iframe source", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&chatMode=%20%20",
),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe",
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(shouldUseChatsIframeProtocol("?from=iframe&hideChrome=1"), false);
assert.equal(hasIframePresentationOverride("?from=iframe&embed=1"), true);
});
test("redirects chats_iframe routes without the iframe source to standard full chat", () => {
@@ -139,32 +182,53 @@ test("forces the iframe theme on the login route without changing normal login",
),
undefined,
);
assert.equal(
resolveForcedThemeForPath(
"/login",
"?from=iframe&hideChrome=1&theme=dark-blue",
),
undefined,
);
});
test("preserves the iframe theme in the post-login redirect", () => {
test("does not add chatMode=iframe to the legacy post-login redirect", () => {
assert.equal(
buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
buildChatsIframeRedirectPath("?from=iframe"),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe",
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
buildChatsIframeRedirectPath(
"?from=iframe&theme=dark-blue&ticket=secret&authToken=secret",
),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
});
test("keeps the iframe redirect ahead of login-method fallbacks", () => {
test("uses full chat after login when a legacy iframe link sets presentation", () => {
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&theme=dark-blue&yUserId=user-1",
"/page/workspace/chats/new",
),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue",
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&hideChrome=1&theme=dark-blue",
"/page/workspace/chats/new",
),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&embed=1&theme=dark-blue",
"/page/workspace/chats/new",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(
resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"),
@@ -179,7 +243,40 @@ test("preserves iframe theme when a new chat becomes a thread", () => {
"thread-123",
"?from=iframe&theme=dark-blue&ticket=secret",
),
"/page/workspace/chats_iframe/thread-123?from=iframe&chatMode=iframe&theme=dark-blue",
"/page/workspace/chats_iframe/thread-123?from=iframe&theme=dark-blue",
);
});
test("normalizes direct regular-chat legacy links according to the same split", () => {
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"?from=iframe",
),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/thread-123",
"?from=iframe&hideChrome=1",
),
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"?from=iframe&embed=1&theme=dark-blue",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
});
test("builds a standard full-chat URL without leaking the legacy marker", () => {
assert.equal(
buildStandardChatRedirectPath(
"?from=iframe&hideHeader=1&ticket=secret",
),
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
);
});
@@ -2,15 +2,111 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
const IFRAME_LOGIN_SOURCE = "iframe";
const STANDARD_CHATS_PATH = "/page/workspace/chats";
function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
}
/** Keep the accepted URL booleans aligned with embed-session.ts. */
function isEnabledPresentationParam(search: string, key: string): boolean {
const value = toSearchParams(search).get(key)?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
export function isChatsIframeLogin(search: string): boolean {
return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE;
}
function isExplicitFullChatMode(search: string): boolean {
return (
toSearchParams(search).get("chatMode")?.trim().toLowerCase() === "full"
);
}
/**
* A bare login URL embedded by an external host has no query parameter to
* survive the login redirect. Add the legacy source marker before building
* the private chat URL so refreshes and subsequent thread navigation retain
* the selected dedicated iframe workspace.
*/
export function buildEmbeddedIframeRedirectPath(search: string): string {
const source = toSearchParams(search);
if (!source.has("from")) {
source.set("from", IFRAME_LOGIN_SOURCE);
}
return buildChatsIframeRedirectPath(source.toString());
}
/**
* `from=iframe` predates the regular embedded-chat presentation flags. Keep
* the two concerns separate: this only answers whether the caller explicitly
* asked PageLayout to alter global chrome, not whether it is an iframe page.
*/
export function hasIframePresentationOverride(search: string): boolean {
return (
isEnabledPresentationParam(search, "hideHeader") ||
isEnabledPresentationParam(search, "hideChrome") ||
isEnabledPresentationParam(search, "embed")
);
}
/**
* The private `chats_iframe` transport is the legacy default for
* `from=iframe`. An explicit `chatMode=iframe` always wins, while a caller
* that also supplies one of the standard presentation flags deliberately uses
* the ordinary full chat workspace instead.
*/
export function shouldUseChatsIframeProtocol(
search: string,
embeddedHost = false,
): boolean {
return (
!hasIframePresentationOverride(search) &&
(isExplicitBrainyIframeMode(search) ||
isChatsIframeLogin(search) ||
// The historical bare iframe integration enters `/login/admin` without
// any route parameters. Treat that embedded context as the legacy
// dedicated AgentSelector page, unless the caller explicitly selected
// the standard full-chat mode.
(embeddedHost && !isExplicitFullChatMode(search)))
);
}
/**
* Build the regular full-chat destination for a legacy iframe entry that has
* opted into a standard presentation mode. Keeping the display flags in the
* address bar makes direct links and refreshes deterministic; `from` is
* intentionally omitted so the legacy protocol cannot take over again.
*/
export function buildStandardChatRedirectPath(
search: string,
fallbackPath = `${STANDARD_CHATS_PATH}/new`,
): string {
const source = toSearchParams(search);
const [pathname, existingQuery = ""] = fallbackPath.split("?");
const target = new URLSearchParams(existingQuery);
for (const key of ["hideHeader", "hideChrome", "embed"] as const) {
const value = source.get(key)?.trim();
if (value && !target.has(key)) {
target.set(key, value);
}
}
// `theme` is meaningful only for embed mode, but preserving it here keeps a
// refreshed embedded page on the same forced theme before session storage is
// restored.
const theme = source.get("theme")?.trim();
if (theme && target.get("embed")) {
target.set("theme", theme);
}
target.set("chatMode", "full");
const query = target.toString();
return query ? `${pathname}?${query}` : pathname;
}
/**
* Preserve only the presentation parameters consumed by the iframe workspace.
* Login credentials must never be copied into the post-login address bar.
@@ -24,13 +120,11 @@ export function buildChatsIframeRedirectPath(search: string): string {
}
const requestedMode = source.get("chatMode")?.trim();
if (requestedMode) {
if (
requestedMode?.toLowerCase() === "iframe" ||
(!isChatsIframeLogin(search) && requestedMode)
) {
target.set("chatMode", requestedMode);
} else if (isChatsIframeLogin(search)) {
// Plugin links historically identify this protocol with `from=iframe`.
// Normalize that legacy marker into the explicit mode before handing the
// URL to the workspace route, so it does not fall back to the full chat.
target.set("chatMode", "iframe");
}
const theme = source.get("theme")?.trim();
@@ -57,8 +151,12 @@ export function resolvePostLoginRedirectPath(
search: string,
fallbackPath: string,
): string {
return isChatsIframeLogin(search)
? buildChatsIframeRedirectPath(search)
if (shouldUseChatsIframeProtocol(search)) {
return buildChatsIframeRedirectPath(search);
}
return isChatsIframeLogin(search) && hasIframePresentationOverride(search)
? buildStandardChatRedirectPath(search, fallbackPath)
: fallbackPath;
}
@@ -69,13 +167,88 @@ export function isChatsIframeWorkspacePath(pathname: string): boolean {
);
}
/**
* A `from=iframe` link can arrive directly at a regular chat URL instead of
* passing through LoginPage. Normalize both entry paths to the same dedicated
* protocol workspace, including an existing thread id when one is supplied.
*/
export function resolveChatsIframeEntryRedirect(
pathname: string,
search: string,
embeddedHost = false,
): string | null {
const isStandardChatPath =
pathname === STANDARD_CHATS_PATH ||
pathname === `${STANDARD_CHATS_PATH}/` ||
pathname.startsWith(`${STANDARD_CHATS_PATH}/`);
// Standard presentation flags deliberately bypass the private iframe
// protocol. Normalize direct legacy URLs once so the result stays on the
// normal full-chat workspace and retains its agent/history sidebar.
if (
isChatsIframeLogin(search) &&
hasIframePresentationOverride(search) &&
!isExplicitBrainyIframeMode(search)
) {
return isStandardChatPath
? buildStandardChatRedirectPath(search, pathname)
: null;
}
const inferredEmbeddedProtocol =
embeddedHost &&
!hasIframePresentationOverride(search) &&
!isExplicitFullChatMode(search) &&
!isChatsIframeLogin(search) &&
!isExplicitBrainyIframeMode(search);
const protocolSearch = inferredEmbeddedProtocol
? `?${new URLSearchParams({
...Object.fromEntries(toSearchParams(search)),
from: IFRAME_LOGIN_SOURCE,
}).toString()}`
: search;
const requestsIframeProtocol = shouldUseChatsIframeProtocol(
protocolSearch,
embeddedHost,
);
if (!requestsIframeProtocol) {
return null;
}
if (pathname === STANDARD_CHATS_PATH || pathname === `${STANDARD_CHATS_PATH}/`) {
return buildChatsIframeRedirectPath(protocolSearch);
}
if (!pathname.startsWith(`${STANDARD_CHATS_PATH}/`)) {
return null;
}
const routeSuffix = pathname.slice(`${STANDARD_CHATS_PATH}/`.length);
if (!routeSuffix || routeSuffix.includes("/")) {
return null;
}
if (routeSuffix === "new") {
return buildChatsIframeRedirectPath(protocolSearch);
}
try {
return buildChatsIframeThreadPath(
"/page/workspace",
decodeURIComponent(routeSuffix),
protocolSearch,
);
} catch {
return null;
}
}
/**
* The lightweight iframe page is a private transport for the Brainy
* postMessage protocol, not a general-purpose embedded chat page.
*
* Requiring an explicit mode prevents legacy `from=iframe` links, ordinary
* iframes and micro-frontend containers from accidentally landing on the
* protocol-only page.
* `from=iframe` is the legacy equivalent of the explicit mode. Ordinary
* iframe/Wujie embedding without either marker remains standard chat.
*/
export function isExplicitBrainyIframeMode(search: string): boolean {
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
@@ -95,25 +268,32 @@ export function resolveStandardChatFallback(
pathname: string,
search: string,
): string | null {
if (!isChatsIframeWorkspacePath(pathname) || isExplicitBrainyIframeMode(search)) {
if (!isChatsIframeWorkspacePath(pathname)) {
return null;
}
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
const params = new URLSearchParams(normalizedSearch);
const requestedMode = params.get("chatMode")?.trim();
if (isChatsIframeLogin(search) && !requestedMode) {
params.set("chatMode", "iframe");
const targetSearch = params.toString();
return targetSearch ? `${pathname}?${targetSearch}` : pathname;
}
const routePrefix = "/page/workspace/chats_iframe/";
const routeSuffix = pathname.slice(routePrefix.length);
const targetPath =
routeSuffix && routeSuffix !== "new"
? `/page/workspace/chats/${routeSuffix}`
: "/page/workspace/chats/new";
if (
isChatsIframeLogin(search) &&
hasIframePresentationOverride(search) &&
!isExplicitBrainyIframeMode(search)
) {
return buildStandardChatRedirectPath(search, targetPath);
}
if (shouldUseChatsIframeProtocol(search)) {
// The legacy marker itself is enough. Do not add chatMode=iframe here: it
// is an implementation detail and adding it caused a self-redirect loop.
return null;
}
params.delete("from");
params.set("chatMode", "full");
const targetSearch = params.toString();
@@ -131,7 +311,7 @@ export function resolveForcedThemeForPath(
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
if (
isChatsIframeWorkspacePath(pathname) ||
(isLoginPath && isChatsIframeLogin(search))
(isLoginPath && shouldUseChatsIframeProtocol(search))
) {
const rawTheme = toSearchParams(search).get("theme")?.trim();
return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light";
@@ -270,6 +270,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/positions",
adminOnly: true,
},
{
id: "mgmt-position-roles",
label: "岗位角色配置",
icon: Briefcase,
path: "/page/strategy/admin/position-roles",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",