Files
deerflow-code/frontend-web/src/pages/LoginPage.tsx
2026-07-29 21:53:32 +08:00

523 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string, unknown> | null {
try {
const raw = authStorageGet('userInfo');
if (!raw) return null;
const info = JSON.parse(raw) as Record<string, unknown>;
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<LoginStatus>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
// 无界(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 (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
<div className="w-full max-w-lg rounded-xl border bg-card p-6 shadow-sm">
<h1 className="text-xl font-semibold">正在进入智能体模式</h1>
{status === 'error' && (
<p className="mt-4 text-sm text-red-600">
登录失败:{errorMessage || '未知错误'}
</p>
)}
</div>
</div>
);
}