'use client'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useNavigate } from 'react-router-dom'; import { useEffect, useMemo, useState } from 'react'; import { useTheme } from '@/contexts/ThemeContext'; import { loginByToken, loginByUsername } from '@/core/auth/api'; import { setStoredAuth, setStoredUserName } from '@/core/auth'; import { getRouteKeyLoginPassword, resolveRouteKey, resolveRouteKeyMapping, ROUTE_KEY_PARAM, } from '@/core/auth/route-key-login'; import { persistExternalLoginParams, readExternalLoginParams, resolveThemeFromQuery, } from '@/core/auth/external-login'; import { fetchCopTaskDetail, persistCopTaskDetail, resolveDeeplinkTaskId, resolveTaskDeeplinkTarget, } from '@/core/auth/task-deeplink'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; import { isEmbeddedHost } from '@/core/embed/frame-context'; import { readEmbedParam } from '@/core/embed/embed-session'; import { GENERAL_QA_ROUTE, loginByYUserId, persistAccessToken, persistToolsToken, } from '@/core/auth/yuser-login'; import { buildStandardChatRedirectPath, buildEmbeddedIframeRedirectPath, buildChatsIframeRedirectPath, hasIframePresentationOverride, isChatsIframeLogin, resolveChatsIframeEntryRedirect, resolvePostLoginRedirectPath, shouldUseChatsIframeProtocol, } from '@/core/page-layout/chats-iframe-route'; import { isSidebarEnabledFromSearch, persistSidebarEnabled, } from '@/core/page-layout/sidebar-enabled'; import { fetchYLogConfig } from '@/core/log/y-log-config'; import { persistYLogToken } from '@/core/log/y-log-token'; import { persistLoginCredentials } from '@/core/auth/login-credentials'; import { OptType, writeYLog } from '@/core/log/y-log'; import { authStorageGet } from '@/core/auth/scoped-storage'; /** 令牌脱敏:日志里只露头尾,避免完整令牌泄漏。 */ function maskLoginToken(value: string): string { if (!value) return ''; if (value.length <= 10) return `${value.slice(0, 2)}***(${value.length})`; return `${value.slice(0, 6)}...${value.slice(-4)}(${value.length})`; } /** 读取已落地的 userInfo(脱敏 access token 后)供登录后排查。 */ function summarizeStoredUserInfo(): Record | null { try { const raw = authStorageGet('userInfo'); if (!raw) return null; const info = JSON.parse(raw) as Record; return { keys: Object.keys(info), yUserId: info.yUserId, userId: info.userId, userName: info.userName, gateDepartName: info.gateDepartName, typeName: info.typeName, access_token: maskLoginToken(String(info.access_token ?? '')), accessToken: maskLoginToken(String(info.accessToken ?? '')), }; } catch { return null; } } const DEFAULT_USERNAME = 'guest'; const IFRAME_CHAT_MODE = 'iframe'; const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new'; const FULL_CHAT_MODE = 'full'; type LoginStatus = 'loading' | 'success' | 'error'; export default function LoginPage() { const { username } = useParams<{ username?: string }>(); const router = useRouter(); const navigate = useNavigate(); const searchParams = useSearchParams(); const { setTheme } = useTheme(); const sidebarEnabled = useMemo( () => isSidebarEnabledFromSearch(searchParams.toString()), [searchParams], ); // iframe 嵌入模式(外部系统 `&embed=1`):去 chrome + 按标签页强制主题。嵌入侧**不**走 // setTheme / persistSidebarEnabled(那俩落 localStorage、会跨标签污染正常系统页),改用 // sessionStorage 嵌入会话(见 embed-session.ts)。`embed=0` 显式关闭。 const isEmbed = useMemo( () => readEmbedParam(searchParams.toString()) === 'on', [searchParams], ); const requestedChatMode = searchParams.get('chatMode')?.trim().toLowerCase() ?? ''; const iframePresentationOverride = useMemo( () => hasIframePresentationOverride(searchParams.toString()), [searchParams], ); const embeddedHost = useMemo(() => isEmbeddedHost(), []); // `from=iframe` is the established entry contract for the dedicated iframe // page. A standard presentation flag is the one intentional exception: it // uses the normal full chat workspace so Header/PageSidebar visibility is // controlled by hideHeader/hideChrome/embed instead. const brainyIframeRequested = shouldUseChatsIframeProtocol( searchParams.toString(), embeddedHost, ); // An explicit full-chat request and all standard presentation flags retain // the regular workspace. Otherwise native iframe/Wujie hosts use the // legacy dedicated AgentSelector entry, matching the established bare // iframe login integration. const fullChatRequested = useMemo( () => !brainyIframeRequested && (isWujieEnv() || requestedChatMode === FULL_CHAT_MODE || // hideHeader/hideChrome/embed are explicit presentation contracts. // They must retain their query parameters and enter the standard // workspace even when a native iframe has no legacy `from` marker. iframePresentationOverride), [ brainyIframeRequested, iframePresentationOverride, requestedChatMode, searchParams, ], ); const isIframeLogin = useMemo( () => brainyIframeRequested, [brainyIframeRequested], ); const resolvedUsername = useMemo(() => { const raw = username?.trim(); return raw && raw.length > 0 ? raw : DEFAULT_USERNAME; }, [username]); // 外部跳转参数:普通登录的 theme 调 setTheme;iframe 登录由 forcedTheme 接管。 // ticket/userId/env 仍按既有登录存储策略持久化。 const yUserId = searchParams.get('yUserId')?.trim() ?? ''; const redirectPath = useMemo( () => { // Keep the mode on the destination URL so PageRoutes can retain the // full chat workspace sidebar while hideChrome hides only the global // application chrome. if (brainyIframeRequested) { return !isChatsIframeLogin(searchParams.toString()) && requestedChatMode !== IFRAME_CHAT_MODE ? buildEmbeddedIframeRedirectPath(searchParams.toString()) : buildChatsIframeRedirectPath(searchParams.toString()); } if (fullChatRequested) { return buildStandardChatRedirectPath( searchParams.toString(), DEFAULT_REDIRECT_PATH, ); } // External iframe callers frequently also provide yUserId for login. // That identity parameter must not steal a deliberate iframe entry and // send it to the generic full-chat home before the route split above. if (yUserId) return GENERAL_QA_ROUTE; return resolvePostLoginRedirectPath( searchParams.toString(), DEFAULT_REDIRECT_PATH, ); }, [ brainyIframeRequested, fullChatRequested, requestedChatMode, searchParams, yUserId, ], ); const toolToken = searchParams.get('token')?.trim() ?? ''; // 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参 const access_token = searchParams.get('access_token')?.trim() ?? ''; // 用户名直登口令门:开启后端开关时,口令从 ?password= 透传 const gatePassword = searchParams.get('password') ?? ''; // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) const authToken = searchParams.get('authToken')?.trim() ?? ''; const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? ''; const routeKeyMapping = useMemo( () => resolveRouteKeyMapping(routeKey), [routeKey], ); const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]); const configuredRouteLoginPassword = useMemo( () => (routeKeyTarget ? getRouteKeyLoginPassword() : ''), [routeKeyTarget], ); const effectiveGatePassword = configuredRouteLoginPassword || gatePassword; const usesConfiguredRouteLoginPassword = Boolean(configuredRouteLoginPassword); // 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页 const taskId = searchParams.get('taskId')?.trim() ?? ''; const goPath = searchParams.get('goPath')?.trim() ?? ''; // 通用「登录后跳指定页」:?redirect=page/strategy/dashboard-agent(值可不带前导斜杠, // 也可自带 query,如 page/strategy/dashboard-agent?taskId=1580——记得整体 URL 编码)。 // 优先级最高:显式指定即按它跳,先于 taskId/goPath 深链与默认首页。出于安全只接受**站内 // 相对路径**:拒绝带协议的绝对 URL(http(s):、javascript: 等)、协议相对 URL(//host)与反斜杠, // 避免开放式跳转;缺前导斜杠时自动补全。 // // 参数透传:登录 URL 上「非登录自身消费」的其余 query(如 taskId / hideChrome)会被透传到 // 目标页 query——这样 `?redirect=/page/strategy/dashboard-agent&taskId=3334` 也能让目标页拿到 // taskId,无需把它编进 redirect 值里。目标已自带的同名参数优先(不覆盖);登录鉴权类参数 // (token/authToken/ticket 等)一律**不透传**,避免泄漏到应用页地址栏。 const redirectTo = useMemo(() => { const raw = routeKeyTarget || searchParams.get('redirect')?.trim() || searchParams.get('route')?.trim() || ''; if (!raw) return ''; if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//') || raw.includes('\\')) return ''; const normalized = raw.startsWith('/') ? raw : `/${raw}`; const [pathOnly, ownQuery = ''] = normalized.split('?'); const target = new URLSearchParams(ownQuery); const iframeEntryTarget = resolveChatsIframeEntryRedirect( pathOnly, searchParams.toString(), embeddedHost, ); if (iframeEntryTarget) { return iframeEntryTarget; } // Keep the Wujie/full-chat decision on the target URL. The destination // route uses it to retain the full agent/history workspace sidebar. if ( fullChatRequested && pathOnly === DEFAULT_REDIRECT_PATH && !target.has('chatMode') ) { target.set('chatMode', FULL_CHAT_MODE); } // 登录流程自身消费的参数,不透传到目标页(尤以鉴权类为甚)。 const LOGIN_OWNED = new Set([ 'redirect', 'route', 'ticket', 'userId', 'env', 'theme', 'yUserId', 'token', 'access_token', 'password', 'authToken', 'goPath', 'from', ROUTE_KEY_PARAM, ]); new URLSearchParams(searchParams.toString()).forEach((value, key) => { if (LOGIN_OWNED.has(key)) return; if (target.has(key)) return; // 目标自带优先 target.set(key, value); }); const qs = target.toString(); return qs ? `${pathOnly}?${qs}` : pathOnly; }, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]); const externalParams = useMemo( () => ({ theme: searchParams.get('theme') ?? '', ticket: searchParams.get('ticket') ?? '', userId: searchParams.get('userId') ?? '', env: searchParams.get('env') ?? '', }), [searchParams], ); const [status, setStatus] = useState('loading'); const [errorMessage, setErrorMessage] = useState(''); // 无界(wujie)父级通过 $wujie.props 注入的参数(ticket / userId / theme / env)。 // hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。 const wujieProps = useWujieProps(); /** onlineTicket:无界 props 优先,其次 URL query,最后读 localStorage */ const onlineTicket = useMemo(() => { const fromWujie = wujieProps.ticket?.trim() ?? ""; const fromQuery = externalParams.ticket.trim(); if (fromWujie || fromQuery) { return fromWujie || fromQuery; } return readExternalLoginParams().ticket.trim(); }, [wujieProps.ticket, externalParams.ticket]); // 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。 useEffect(() => { if (!isWujieEnv()) return; console.log('[wujie] sub-app props:', wujieProps); }, [wujieProps]); // 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致 // (dark-blue → 深色;red → 浅色;blue 暂无该主题,先用浅色代替),统一走 resolveThemeFromQuery。 // **嵌入模式和 chats_iframe 登录跳过**:setTheme 会写 localStorage `strategy-theme`, // 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。 useEffect(() => { if (isEmbed || isIframeLogin) return; // routeKey 中显式配置的主题优先于调用方 URL,确保短链无需额外拼 // `theme=dark-blue` 也能在登录完成前切换到指定主题。 const themeValue = routeKeyMapping?.theme || wujieProps.theme || externalParams.theme; if (!themeValue) return; setTheme(resolveThemeFromQuery(themeValue)); }, [ isEmbed, isIframeLogin, routeKeyMapping?.theme, wujieProps.theme, externalParams.theme, setTheme, ]); // ticket / userId / env 持久化(与 login 请求并行做,互不阻塞) useEffect(() => { persistExternalLoginParams({ ticket: onlineTicket || externalParams.ticket, userId: externalParams.userId, env: externalParams.env, }); }, [onlineTicket, externalParams.ticket, externalParams.userId, externalParams.env]); useEffect(() => { let cancelled = false; async function runLogin() { setStatus('loading'); setErrorMessage(''); // ── 调试:登录路由参数全打印 ────────────────────────────────────────────── // token / yUserId / 用户名 / ticket 任一方式都打印(令牌脱敏),方便排查 // “userInfo 取不到值 / 传值不对” 一类问题。`embed=0`/正常态也照打。 const loginMethod = authToken ? 'token(authToken)' : yUserId ? 'yUserId' : onlineTicket ? 'onlineTicket' : toolToken ? 'toolsToken' : 'username'; console.info('[login] route params', { method: loginMethod, pathUsername: resolvedUsername, yUserId, access_token: maskLoginToken(access_token), toolToken: maskLoginToken(toolToken), authToken: maskLoginToken(authToken), routeKey, configuredRouteLoginPassword: usesConfiguredRouteLoginPassword, gatePassword: effectiveGatePassword ? maskLoginToken(effectiveGatePassword) : '', onlineTicket: onlineTicket ? maskLoginToken(onlineTicket) : '', taskId, goPath, redirect: redirectTo, isEmbed, rawSearch: searchParams.toString(), }); try { let auth; let loginUserName = ''; // 外部用户信息(yUserId → consumer 用户详情)与登录鉴权方式**解耦**:只要 URL // 带 yUserId 就拉一次 userDetailByYuserId,把 yUserId/userName/gateDepartName/ // typeName/accessToken 落进 userInfo。之前 token(authToken) 登录会短路掉这一步, // 导致 token 登录后 userInfo 残缺(只剩 userName)、iframe/轻应用拿不到这些值。 if (yUserId) { if (authToken) { // token 登录下 consumer 失败不应阻断登录(仅影响 userInfo 的富信息)。 try { await loginByYUserId(yUserId, toolToken || null, access_token || null); } catch (error) { console.warn('[login] loginByYUserId failed under token login (non-fatal)', error); } } else { await loginByYUserId(yUserId, toolToken || null, access_token || null); } } if (authToken) { // token 换登录:后端用 authToken 调上游接口换出用户名再登录 auth = await loginByToken(authToken); // 让 token 登录也写入正确用户名(后端 getTokenInfo 解析出的原始登录名)。 // 若上面 yUserId 已从 consumer 落了 userName,则 setStoredUserName 不会覆盖它。 loginUserName = auth.username?.trim() ?? ''; } else { let deerflowUsername = resolvedUsername; // yUserId 优先(已在上面落好 userInfo + tools_token);其余方式仅在无 yUserId 时启用。 if (!yUserId && onlineTicket) { deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket); } else if (!yUserId && toolToken) { persistToolsToken(toolToken); } auth = await loginByUsername(deerflowUsername, effectiveGatePassword); loginUserName = deerflowUsername; } if (cancelled) { return; } setStoredAuth(auth); // 操作日志(Y-Log):只有 token 登录(存在 token1=authToken)才上报。 // 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。 if (authToken) { persistYLogToken(authToken); // 存下 token1 + 登录用户名(getTokenInfo 解析出的原始名,后端回传),供任务工作区 // 跳转按钮的「追加登录态」拼到外链上(仅 token 登录才有这两个值)。 persistLoginCredentials(authToken, auth); void fetchYLogConfig(); writeYLog({ optType: OptType.login, optDetail: 'CM助手-全量-登录', url: '/api/v1/auth/login/token', reqParam: { username: loginUserName || auth.user_id }, returnParam: { user_id: auth.user_id }, }); } // 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖) setStoredUserName(loginUserName); // 非 yUserId 登录仍可直接持久化 access_token;yUserId 登录已在 loginByYUserId 内按 // “接口 accessToken 优先,URL access_token 仅兜底”的规则处理。 if (!yUserId) { persistAccessToken(access_token); } // ── 调试:登录落地结果(auth + 实际写入的 userInfo,access token 已脱敏) ── console.info('[login] stored result', { method: loginMethod, user_id: auth.user_id, email: auth.email, authUsername: auth.username, loginUserName, userInfo: summarizeStoredUserInfo(), }); // 嵌入模式不写 sidebarEnabled(localStorage 的「简洁/全量」偏好会跨标签污染正常页); // 去 chrome 由按标签页的嵌入会话(isEmbedActive)驱动,见 PageLayout。 if (!isEmbed) persistSidebarEnabled(sidebarEnabled); setStatus('success'); // 通用跳转:显式 ?redirect= 指定站内页面时,登录后直接跳过去(优先级最高, // 先于 taskId/goPath 深链与默认首页)。value 自带的 query 会被原样保留。 if (redirectTo) { navigate(redirectTo, { replace: true }); return; } // 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页; // 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。 if (taskId && goPath) { try { // xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。 const realTaskId = await resolveDeeplinkTaskId(goPath, taskId); if (cancelled) return; const detail = await fetchCopTaskDetail(realTaskId); if (cancelled) return; if (detail) { persistCopTaskDetail(detail); const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId); if (cancelled) return; if (target) { navigate(target.path, { replace: true, state: target.state }); return; } } } catch { /* 深链失败不阻断登录,落默认首页 */ } } router.replace(redirectPath); } catch (error) { if (cancelled) { return; } setStatus('error'); setErrorMessage( error instanceof Error ? error.message : 'Login request failed.', ); } } void runLogin(); return () => { cancelled = true; }; }, [ resolvedUsername, router, navigate, sidebarEnabled, yUserId, toolToken, access_token, onlineTicket, authToken, effectiveGatePassword, usesConfiguredRouteLoginPassword, routeKey, gatePassword, taskId, goPath, redirectTo, redirectPath, isEmbed, ]); return (

正在进入智能体模式

{status === 'error' && (

登录失败:{errorMessage || '未知错误'}

)}
); }