diff --git a/frontend-web/src/core/embed/embed-session.ts b/frontend-web/src/core/embed/embed-session.ts index 8e0f8a32..19d60d64 100644 --- a/frontend-web/src/core/embed/embed-session.ts +++ b/frontend-web/src/core/embed/embed-session.ts @@ -1,3 +1,7 @@ +import { resolveEmbedThemeMode } from "./theme-mode.mjs"; + +export { resolveEmbedThemeMode } from "./theme-mode.mjs"; + /** * 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL * 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`), @@ -85,16 +89,6 @@ export function readIsolateParam(search: string): FlagParam { return readFlagParam(search, "isolate"); } -/** - * 把外部传入的主题色映射成离散明/暗。与 `resolveThemeFromQuery` 同源约定(dark-blue → 深色), - * 另外宽松接受 `dark` / 中文「深色/深蓝」直传;其余(含空)一律浅色。 - */ -export function resolveEmbedThemeMode(raw: string | null | undefined): EmbedTheme { - const v = (raw ?? "").trim().toLowerCase(); - if (v === "dark" || v === "dark-blue" || v === "深色" || v === "深蓝") return "dark"; - return "light"; -} - /** 取 URL query 里的 `theme` 值(embed 主题来源)。 */ export function readEmbedThemeParam(search: string): string { return (toSearchParams(search).get("theme") ?? "").trim(); diff --git a/frontend-web/src/core/embed/theme-mode.mjs b/frontend-web/src/core/embed/theme-mode.mjs new file mode 100644 index 00000000..0a2b1aec --- /dev/null +++ b/frontend-web/src/core/embed/theme-mode.mjs @@ -0,0 +1,19 @@ +/** + * @typedef {"light" | "dark"} EmbedThemeMode + */ + +/** + * Map external theme names to the two themes supported by iframe surfaces. + * + * @param {string | null | undefined} rawTheme + * @returns {EmbedThemeMode} + */ +export function resolveEmbedThemeMode(rawTheme) { + const theme = (rawTheme ?? "").trim().toLowerCase(); + return theme === "dark" || + theme === "dark-blue" || + theme === "深色" || + theme === "深蓝" + ? "dark" + : "light"; +} diff --git a/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs b/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs index 5cdb649b..43881a79 100644 --- a/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs +++ b/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + buildChatsIframeRedirectPath, + buildChatsIframeThreadPath, isChatsIframeWorkspacePath, + resolvePostLoginRedirectPath, resolveForcedThemeForPath, } from "./chats-iframe-route.ts"; @@ -48,3 +51,73 @@ test("honors the dark-blue theme query on chats_iframe routes", () => { "light", ); }); + +test("forces the iframe theme on the login route without changing normal login", () => { + assert.equal( + resolveForcedThemeForPath( + "/login", + "?from=iframe&theme=dark-blue", + ), + "dark", + ); + assert.equal( + resolveForcedThemeForPath( + "/login/guest", + "?from=iframe&theme=dark-blue", + ), + "dark", + ); + assert.equal( + resolveForcedThemeForPath("/login", "?theme=dark-blue"), + undefined, + ); + assert.equal( + resolveForcedThemeForPath( + "/page/workspace/chats/new", + "?from=iframe&theme=dark-blue", + ), + undefined, + ); +}); + +test("preserves the iframe theme in the post-login redirect", () => { + assert.equal( + buildChatsIframeRedirectPath("?from=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", + ); + assert.equal( + buildChatsIframeRedirectPath( + "?from=iframe&theme=dark-blue&ticket=secret&authToken=secret", + ), + "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue", + ); +}); + +test("keeps the iframe redirect ahead of login-method fallbacks", () => { + assert.equal( + resolvePostLoginRedirectPath( + "?from=iframe&theme=dark-blue&yUserId=user-1", + "/page/workspace/chats/new", + ), + "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue", + ); + assert.equal( + resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"), + "/page/workspace/chats/new", + ); +}); + +test("preserves iframe theme when a new chat becomes a thread", () => { + assert.equal( + buildChatsIframeThreadPath( + "/page/workspace", + "thread-123", + "?from=iframe&theme=dark-blue&ticket=secret", + ), + "/page/workspace/chats_iframe/thread-123?from=iframe&theme=dark-blue", + ); +}); diff --git a/frontend-web/src/core/page-layout/chats-iframe-route.ts b/frontend-web/src/core/page-layout/chats-iframe-route.ts index 06b13117..45f85663 100644 --- a/frontend-web/src/core/page-layout/chats-iframe-route.ts +++ b/frontend-web/src/core/page-layout/chats-iframe-route.ts @@ -1,8 +1,60 @@ -import { resolveEmbedThemeMode } from "@/core/embed/embed-session"; +import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs"; + +const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new"; +const IFRAME_LOGIN_SOURCE = "iframe"; + +function toSearchParams(search: string): URLSearchParams { + return new URLSearchParams(search.startsWith("?") ? search : `?${search}`); +} + +export function isChatsIframeLogin(search: string): boolean { + return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE; +} + +/** + * Preserve only the presentation parameters consumed by the iframe workspace. + * Login credentials must never be copied into the post-login address bar. + */ +export function buildChatsIframeRedirectPath(search: string): string { + const source = toSearchParams(search); + const target = new URLSearchParams(); + + if (isChatsIframeLogin(search)) { + target.set("from", IFRAME_LOGIN_SOURCE); + } + + const theme = source.get("theme")?.trim(); + if (theme) { + target.set("theme", theme); + } + + const query = target.toString(); + return query ? `${CHATS_IFRAME_PATH}?${query}` : CHATS_IFRAME_PATH; +} + +export function buildChatsIframeThreadPath( + routeBase: string, + threadId: string, + search: string, +): string { + const base = routeBase.endsWith("/") ? routeBase.slice(0, -1) : routeBase; + const query = buildChatsIframeRedirectPath(search).split("?")[1]; + const path = `${base}/chats_iframe/${encodeURIComponent(threadId)}`; + return query ? `${path}?${query}` : path; +} + +export function resolvePostLoginRedirectPath( + search: string, + fallbackPath: string, +): string { + return isChatsIframeLogin(search) + ? buildChatsIframeRedirectPath(search) + : fallbackPath; +} export function isChatsIframeWorkspacePath(pathname: string): boolean { return ( - pathname === "/page/workspace/chats_iframe/new" || + pathname === CHATS_IFRAME_PATH || pathname.startsWith("/page/workspace/chats_iframe/") ); } @@ -15,9 +67,12 @@ export function resolveForcedThemeForPath( return "light"; } - if (isChatsIframeWorkspacePath(pathname)) { - const normalizedSearch = search.startsWith("?") ? search : `?${search}`; - const rawTheme = new URLSearchParams(normalizedSearch).get("theme")?.trim(); + const isLoginPath = pathname === "/login" || pathname.startsWith("/login/"); + if ( + isChatsIframeWorkspacePath(pathname) || + (isLoginPath && isChatsIframeLogin(search)) + ) { + const rawTheme = toSearchParams(search).get("theme")?.trim(); return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light"; } diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index 6a6aadae..4bac210f 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -8,6 +8,7 @@ import { ShrinkIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { useArtifacts } from "@/components/workspace/artifacts"; @@ -31,6 +32,7 @@ import { getAccessToken } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; +import { buildChatsIframeThreadPath } from "@/core/page-layout/chats-iframe-route"; import { useRouteBase } from "@/core/route-base"; import { isThreadRagModeLocked, @@ -126,6 +128,7 @@ function postBrainyAuthToken(accessToken: string) { export default function IframeChatPage() { const routeBase = useRouteBase(); + const location = useLocation(); const { t } = useI18n(); const [hasStartedConversation, setHasStartedConversation] = useState(false); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = @@ -230,7 +233,11 @@ export default function IframeChatPage() { history.replaceState( null, "", - `${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`, + `${window.location.pathname}${window.location.search}#${buildChatsIframeThreadPath( + routeBase, + createdThreadId, + location.search, + )}`, ); if (pendingRequestIdRef.current) { @@ -707,4 +714,4 @@ export default function IframeChatPage() { ); -} \ No newline at end of file +} diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 40433aee..70e8094f 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -27,6 +27,10 @@ import { persistAccessToken, persistToolsToken, } from '@/core/auth/yuser-login'; +import { + isChatsIframeLogin, + resolvePostLoginRedirectPath, +} from '@/core/page-layout/chats-iframe-route'; import { isSidebarEnabledFromSearch, persistSidebarEnabled, @@ -66,9 +70,7 @@ function summarizeStoredUserInfo(): Record | null { } const DEFAULT_USERNAME = 'guest'; -const IFRAME_LOGIN_SOURCE = 'iframe'; const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new'; -const IFRAME_REDIRECT_PATH = '/page/workspace/chats_iframe/new'; type LoginStatus = 'loading' | 'success' | 'error'; @@ -89,19 +91,25 @@ export default function LoginPage() { () => readEmbedParam(searchParams.toString()) === 'on', [searchParams], ); - const redirectPath = useMemo( - () => - searchParams.get('from') === IFRAME_LOGIN_SOURCE - ? IFRAME_REDIRECT_PATH - : DEFAULT_REDIRECT_PATH, + const isIframeLogin = useMemo( + () => isChatsIframeLogin(searchParams.toString()), [searchParams], ); const resolvedUsername = useMemo(() => { const raw = username?.trim(); return raw && raw.length > 0 ? raw : DEFAULT_USERNAME; }, [username]); - // 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage + // 外部跳转参数:普通登录的 theme 调 setTheme;iframe 登录由 forcedTheme 接管。 + // ticket/userId/env 仍按既有登录存储策略持久化。 const yUserId = searchParams.get('yUserId')?.trim() ?? ''; + const redirectPath = useMemo( + () => + resolvePostLoginRedirectPath( + searchParams.toString(), + yUserId ? GENERAL_QA_ROUTE : DEFAULT_REDIRECT_PATH, + ), + [searchParams, yUserId], + ); const toolToken = searchParams.get('token')?.trim() ?? ''; // 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参 const access_token = searchParams.get('access_token')?.trim() ?? ''; @@ -176,14 +184,14 @@ export default function LoginPage() { // 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致 // (dark-blue → 深色;red → 浅色;blue 暂无该主题,先用浅色代替),统一走 resolveThemeFromQuery。 - // **嵌入模式跳过**:setTheme 会写 localStorage `strategy-theme`,跨标签污染正常系统页; - // 嵌入主题改由 forcedTheme(按标签页、不持久化)接管。 + // **嵌入模式和 chats_iframe 登录跳过**:setTheme 会写 localStorage `strategy-theme`, + // 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。 useEffect(() => { - if (isEmbed) return; + if (isEmbed || isIframeLogin) return; const themeValue = wujieProps.theme || externalParams.theme; if (!themeValue) return; setTheme(resolveThemeFromQuery(themeValue)); - }, [isEmbed, wujieProps.theme, externalParams.theme, setTheme]); + }, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]); // ticket / userId / env 持久化(与 login 请求并行做,互不阻塞) useEffect(() => { @@ -341,7 +349,7 @@ export default function LoginPage() { } } - router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath); + router.replace(redirectPath); } catch (error) { if (cancelled) { return; @@ -371,6 +379,7 @@ export default function LoginPage() { taskId, goPath, redirectTo, + redirectPath, isEmbed, ]);