diff --git a/CLAUDE.md b/CLAUDE.md index 7dba5283..7e0bd149 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) +- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`. ### Configuration diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts new file mode 100644 index 00000000..c9289b79 --- /dev/null +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -0,0 +1,161 @@ +/** + * 外部系统跳转登录时的「任务深链」:登录 URL 带 ``taskId`` + ``goPath`` 时, + * 拉取任务详情并据 goPath 把用户直接送到对应的智能体问答 / 多智能体会商页。 + * + * 流程: + * 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=``, + * 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。 + * 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。 + * 3. 按 goPath 解析出业务码 + 模式: + * - ``3qfx`` / ``rwfx`` / ``xdfx`` → 单智能体问答(用「配置管理」里该业务配置的智能体) + * - ``3qfx-A`` / ``rwfx-A`` / ``xdfx-A`` → 多智能体会商(用该业务配置的业务链条) + * 携带 taskName / taskContent 作为开场信息。 + * + * 业务码映射与「配置管理」(`/api/business-mapping`) 一致: + * 3Q=三情分析(3QFX)、6BF=任务分析(任务FX)、7BF=行动分析(XDFX)。 + */ + +import { requestCrudService } from "@/service/request-axios-service"; +import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; + +/** consumer 接口返回的任务详情(data 字段)。 */ +export interface CopTaskDetail { + id: string | number | null; + overview: string; + taskDirection: string; + taskName: string; + taskContent: string; +} + +/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */ +export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail"; + +/** goPath 基础码 → 业务码(配置管理 businessCode)。 */ +const GOPATH_BUSINESS_CODE: Record = { + "3qfx": "3Q", // 三情分析 + rwfx: "6BF", // 任务分析(任务FX) + xdfx: "7BF", // 行动分析(XDFX) +}; + +export interface ParsedGoPath { + businessCode: string; + /** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */ + roundtable: boolean; +} + +/** 解析 goPath(大小写不敏感);``-A`` 后缀表示多智能体会商。无法识别返回 null。 */ +export function parseGoPath(goPath: string): ParsedGoPath | null { + const raw = goPath.trim().toLowerCase(); + if (!raw) return null; + const roundtable = raw.endsWith("-a"); + const base = roundtable ? raw.slice(0, -2) : raw; + const businessCode = GOPATH_BUSINESS_CODE[base]; + if (!businessCode) return null; + return { businessCode, roundtable }; +} + +function consumerBaseUrl(): string { + const value = import.meta.env.VITE_AIPI_CONSUMER_URL; + return typeof value === "string" ? value.replace(/\/+$/, "") : ""; +} + +/** 拉取任务详情;缺少 consumer 地址 / 返回不合法时返回 null。 */ +export async function fetchCopTaskDetail(taskId: string): Promise { + const base = consumerBaseUrl(); + if (!base || !taskId) return null; + // service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。 + const body = await requestCrudService.get(`${base}/taskAnalysSearch/cop-task-detail`, { taskId }); + const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; + if (!data || typeof data !== "object") return null; + const d = data as Record; + return { + id: (d.id as string | number | null) ?? null, + overview: typeof d.overview === "string" ? d.overview : "", + taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "", + taskName: typeof d.taskName === "string" ? d.taskName : "", + taskContent: typeof d.taskContent === "string" ? d.taskContent : "", + }; +} + +/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */ +export function persistCopTaskDetail(detail: CopTaskDetail): void { + try { + sessionStorage.setItem(COP_TASK_DETAIL_STORAGE_KEY, JSON.stringify(detail)); + } catch { + /* noop */ + } +} + +/** 读取暂存的任务详情。 */ +export function readCopTaskDetail(): CopTaskDetail | null { + try { + const raw = sessionStorage.getItem(COP_TASK_DETAIL_STORAGE_KEY); + return raw ? (JSON.parse(raw) as CopTaskDetail) : null; + } catch { + return null; + } +} + +export interface TaskDeeplinkTarget { + path: string; + /** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */ + state?: Record; +} + +/** 用 taskName / taskContent 拼出开场信息文本。 */ +function buildOpeningText(detail: CopTaskDetail): string { + return [ + detail.taskName ? `任务名称:${detail.taskName}` : "", + detail.taskContent ? `任务内容:${detail.taskContent}` : "", + ] + .filter(Boolean) + .join("\n"); +} + +/** + * 据 goPath + 任务详情解析跳转目标。 + * - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条)的 state。 + * - 单智能体问答:解析「配置管理」里该业务配置的智能体;把开场信息写入 + * ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。 + * 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。 + */ +export async function resolveTaskDeeplinkTarget( + goPath: string, + detail: CopTaskDetail, +): Promise { + const parsed = parseGoPath(goPath); + if (!parsed) return null; + + if (parsed.roundtable) { + return { + path: "/page/strategy/qa/multi-agent-roundtable", + state: { + roundtablePrefill: { + taskTitle: detail.taskName, + taskGoal: detail.taskContent, + businessCode: parsed.businessCode, + autoStart: true, + }, + }, + }; + } + + let agentId: string | null = null; + try { + const mappings = await listBusinessMappings(); + agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null; + } catch { + agentId = null; + } + if (!agentId) return null; + + try { + sessionStorage.setItem( + "pendingAgentMessage", + JSON.stringify({ agentId, text: buildOpeningText(detail) }), + ); + } catch { + /* noop */ + } + return { path: `/page/strategy/agents/${agentId}/chats/new` }; +} diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 8f33b1da..5b495ea0 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -1,6 +1,7 @@ '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'; @@ -11,6 +12,11 @@ import { readExternalLoginParams, resolveThemeFromQuery, } from '@/core/auth/external-login'; +import { + fetchCopTaskDetail, + persistCopTaskDetail, + resolveTaskDeeplinkTarget, +} from '@/core/auth/task-deeplink'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; import { @@ -34,6 +40,7 @@ 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( @@ -60,6 +67,9 @@ export default function LoginPage() { 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() ?? ''; const externalParams = useMemo( () => ({ theme: searchParams.get('theme') ?? '', @@ -145,6 +155,27 @@ export default function LoginPage() { persistAccessToken(accessToken); persistSidebarEnabled(sidebarEnabled); setStatus('success'); + + // 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页; + // 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。 + if (taskId && goPath) { + try { + const detail = await fetchCopTaskDetail(taskId); + if (cancelled) return; + if (detail) { + persistCopTaskDetail(detail); + const target = await resolveTaskDeeplinkTarget(goPath, detail); + 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) { @@ -164,6 +195,7 @@ export default function LoginPage() { }, [ resolvedUsername, router, + navigate, sidebarEnabled, yUserId, toolToken, @@ -171,6 +203,8 @@ export default function LoginPage() { onlineTicket, authToken, gatePassword, + taskId, + goPath, ]); return (