按钮管理「登录参数」两套实现冲突解决——两分支各自独立给跳转按钮加了登录态追加能力, 二者正交、保留两套,互不影响: - master: login_params (JSON 列 login_params_json,从 userInfo 解析,类似轻应用登录参数) - ji: append_auth + auth_token_param + auth_name_param (token 登录时追加 token1+用户名) 迁移 revision 碰撞修复(两分支都用了 20260625_01):重编号为线性链 20260620_02 → 20260625_01 (login_params) → 20260625_02 (auth_params) → 20260625_03 (users_approved) 并同步 column_additions_mysql.sql 注释里的 migration 编号。 前后端 task_buttons(model/sql/memory/router/前端 type·api·admin 页)均保留两套字段; 两份 CLAUDE.md 文档合并为同时记录两种机制。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
'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 {
|
||
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 { readEmbedParam } from '@/core/embed/embed-session';
|
||
import {
|
||
GENERAL_QA_ROUTE,
|
||
loginByYUserId,
|
||
persistAccessToken,
|
||
persistToolsToken,
|
||
} from '@/core/auth/yuser-login';
|
||
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';
|
||
|
||
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';
|
||
|
||
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 redirectPath = useMemo(
|
||
() =>
|
||
searchParams.get('from') === IFRAME_LOGIN_SOURCE
|
||
? IFRAME_REDIRECT_PATH
|
||
: DEFAULT_REDIRECT_PATH,
|
||
[searchParams],
|
||
);
|
||
const resolvedUsername = useMemo(() => {
|
||
const raw = username?.trim();
|
||
return raw && raw.length > 0 ? raw : DEFAULT_USERNAME;
|
||
}, [username]);
|
||
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
|
||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||
const accessToken = searchParams.get('access_token')?.trim() ?? '';
|
||
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
|
||
const gatePassword = searchParams.get('password') ?? '';
|
||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||
// 任务深链:外部系统带 ?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 = searchParams.get('redirect')?.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 LOGIN_OWNED = new Set([
|
||
'redirect', 'ticket', 'userId', 'env', 'theme', 'yUserId',
|
||
'token', 'access_token', 'password', 'authToken', 'goPath', 'from',
|
||
]);
|
||
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;
|
||
}, [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。
|
||
// **嵌入模式跳过**:setTheme 会写 localStorage `strategy-theme`,跨标签污染正常系统页;
|
||
// 嵌入主题改由 forcedTheme(按标签页、不持久化)接管。
|
||
useEffect(() => {
|
||
if (isEmbed) return;
|
||
const themeValue = wujieProps.theme || externalParams.theme;
|
||
if (!themeValue) return;
|
||
setTheme(resolveThemeFromQuery(themeValue));
|
||
}, [isEmbed, 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('');
|
||
try {
|
||
let auth;
|
||
let loginUserName = '';
|
||
if (authToken) {
|
||
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
|
||
auth = await loginByToken(authToken);
|
||
} else {
|
||
let deerflowUsername = resolvedUsername;
|
||
if (onlineTicket) {
|
||
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
|
||
} else if (yUserId) {
|
||
await loginByYUserId(yUserId, toolToken || null);
|
||
} else if (toolToken) {
|
||
persistToolsToken(toolToken);
|
||
}
|
||
|
||
auth = await loginByUsername(deerflowUsername, gatePassword);
|
||
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);
|
||
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
|
||
// XK/PG 中任务 iframe 据此取 accessToken
|
||
persistAccessToken(accessToken);
|
||
// 嵌入模式不写 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(yUserId ? GENERAL_QA_ROUTE : 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,
|
||
accessToken,
|
||
onlineTicket,
|
||
authToken,
|
||
gatePassword,
|
||
taskId,
|
||
goPath,
|
||
redirectTo,
|
||
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>
|
||
);
|
||
}
|