Files
deerflow-code/frontend-web/src/core/auth/task-buttons.ts
T
董宏凯andCursor 51afebb394 feat: 报告结构支持检索方向/来源与智能新增,并修复协助卡停轮与宿主跳转
为深度研究报告结构增加检索方向与技能来源、对话式智能新增页与详情排版;澄清中间件在 after_model 可靠结束本轮,避免弱模型出卡后继续生成;任务按钮补充 host 跳转与相关深链能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 17:46:06 +08:00

442 lines
17 KiB
TypeScript
Raw 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.
/**
* 任务工作区(任务深链 rwfx / 3qfx / xdfx,见 ``TaskFxWorkspace``)左侧栏的
* **可配置跳转按钮**。
*
* 数据源优先级:后端 ``/api/task-buttons``(全局共享、admin 整体替换)。**仅当整张表
* 为空(全新部署、从未保存过)**时回退到代码种子默认(``defaultTaskButtons``,从实时
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮类型(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)/
* ``purpose`` 弹出任务目的树 / ``host`` iframe 内向父页面 ``postMessage({ type:'HOST_NAVIGATE' })``
* 让宿主系统跳转(见 [[host-navigate]])。
*/
import {
appendQueryParam,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
readCopTaskDetail,
resolveTaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
import { readUserInfo, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
import {
readLoginToken,
readLoginUsername,
} from "@/core/auth/login-credentials";
import {
buildHostNavigateMessage,
isEmbeddedInParent,
isHostNavigatePath,
parseHostTaskId,
readAncestorOrigin,
resolveHostNavigateOrigin,
} from "@/core/auth/host-navigate";
import { readRuntimeEnv } from "@/env";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
/** 任务工作区业务码(与路由 taskBase 一一对应)。 */
export type TaskBusiness = "rwfx" | "3qfx" | "xdfx";
/** 业务元数据:每个 = 一个任务深链页面。``goPath`` 为深链 ``?goPath=`` 值;``desc`` 给管理页说明。 */
export const TASK_BUSINESSES: {
code: TaskBusiness;
/** 页面名(tab 主标题)。 */
name: string;
/** 深链 goPath 值。 */
goPath: string;
/** 页面说明。 */
desc: string;
}[] = [
{
code: "rwfx",
name: "任务分析",
goPath: "rwfx",
desc: "完整版任务工作区(带目的树闸门),通过 ?goPath=rwfx 进入。",
},
{
code: "3qfx",
name: "三情分析",
goPath: "3qfx",
desc: "精简版工作区(无目的树闸门),通过 ?goPath=3qfx 进入。",
},
{
code: "xdfx",
name: "行动分析",
goPath: "xdfx",
desc: "精简版工作区,通过 ?goPath=xdfx 进入(深链 taskId 实为行动id)。",
},
];
/** ``url``=外链 / ``business``=切换业务 / ``purpose``=弹出任务目的树表格 / ``host``=宿主路由(postMessage)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose" | "host";
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
export type TaskButtonIdKind = "task" | "action";
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口(嵌入态即当前 iframe)。 */
export type TaskButtonOpenMode = "blank" | "self";
/**
* ``url`` 外链追加的**登录参数**(与「应用管理」一致):在拼好的跳转地址后再依次
* 追加 ``?{key}={value}``,``value`` 运行时从 ``userInfo`` 按 ``source`` 解析
* (``other`` 时取 ``customValue`` 字面值)。复用 [[light-app-center]] 的取值来源。
*/
export interface TaskButtonLoginParam {
/** 拼到 URL 的查询参数名。 */
key: string;
/** 取值来源:``userInfo`` 字段名(如 ``accessToken`` / ``yUserId``)或 ``other``(自填)。 */
source: string;
/** ``source==="other"`` 时的自定义字面值。 */
customValue: string;
}
/** 一个可配置跳转按钮。 */
export interface TaskButton {
id: string;
business: TaskBusiness;
label: string;
linkType: TaskButtonLinkType;
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``);``host``=宿主系统路由(如 ``/task/...``)。 */
target: string;
/** ``url`` 类型是否追加 id 查询参数。 */
appendTaskId: boolean;
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
idParam: string;
/**
* ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
*/
idKind: TaskButtonIdKind;
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
openMode: TaskButtonOpenMode;
/** ``url`` 外链追加的登录参数列表(仅 ``url`` 类型有意义;为空则不追加,行为同旧版)。 */
loginParams: TaskButtonLoginParam[];
/**
* ``url`` 跳转是否追加登录态:token1(token 登录的上游 token)+ 登录用户名,供外链所在的
* 外部系统识别当前登录用户。**仅 token 登录**(前端存有 token1,见 [[login-credentials]])时
* 实际拼上;非 token 登录读不到 token1,自动跳过(按钮照常跳,只是不带这两个参数)。
*/
appendAuth: boolean;
/** 追加 token 的查询参数名(空则用 ``token``)。 */
authTokenParam: string;
/** 追加用户名的查询参数名(空则用 ``username``)。 */
authNameParam: string;
enabled: boolean;
sortOrder: number;
}
/**
* 运行时解析单个登录参数的值——**与「应用管理」完全一致**:直接复用 light-app 的
* ``resolveRouteParamValue``(把本按钮的登录参数适配成 ``login`` 类型的路由参数),
* 保证取值来源/逻辑跟轻应用一模一样(``other`` 取字面 ``customValue``,否则从
* ``userInfo`` 取 ``source`` 字段)。
*/
function resolveLoginParamValue(
p: TaskButtonLoginParam,
userInfo: Record<string, unknown>,
): string {
return resolveRouteParamValue(
{ key: p.key, paramType: "login", source: p.source, customValue: p.customValue },
userInfo,
);
}
/** 内部切换可选的目标业务(``business`` 类型按钮的下拉项)。 */
export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
{ code: "3qfx", label: "三情分析" },
{ code: "rwfx", label: "任务分析" },
{ code: "xdfx", label: "行动分析" },
];
/**
* 代码种子默认按钮(仅在后端整张表为空时使用):
* - rwfx:历史按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+「上一步」+ 切换到三情/行动分析;
* 「上一步」紧邻「下一步」左侧(底部栏 prev 组在 next 组前),默认指向应用内大屏绘制页(?taskId=任务id);
* - 3qfx / xdfx:互相切换到另外两个业务。
* 即「三个业务都带切换按钮」。已保存过则用页面「导入默认」把缺失项补进来。
*/
export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
const sw = (
id: string,
business: TaskBusiness,
label: string,
target: TaskBusiness,
sortOrder: number,
): TaskButton => ({
id,
business,
label,
linkType: "business",
target,
appendTaskId: false,
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true,
sortOrder,
});
// 目的树弹框按钮(无需 target)。
const pz = (id: string, business: TaskBusiness, sortOrder: number): TaskButton => ({
id,
business,
label: "弹框查看",
linkType: "purpose",
target: "",
appendTaskId: false,
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true,
sortOrder,
});
return [
// rwfx(任务分析):外链 + 切换
{
id: "rwfx-edit",
business: "rwfx",
label: "编辑",
linkType: "url",
target: cfg.treemapEditUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true,
sortOrder: 0,
},
{
// 上一步:放在「下一步」左边(底部快捷跳转栏,prev 组渲染在 next 组之前)。
// 默认指向**应用内**「大屏绘制」页 /page/strategy/dashboard-agent(内部路由,以 / 开头),
// 携带 ?taskId=任务id(大屏页按 taskId 取数)。openMode=self → 同窗 react-router 导航;
// 大屏页路由在 PageLayout 里被**强制显示全局 chrome**(见 PageRoutes 的 forceChromeForDashboardAgent),
// 故即便从去 chrome 的工作区进来也带回顶部导航栏 + 侧边栏(embed=1 外部嵌入态仍隐藏)。
id: "rwfx-prev",
business: "rwfx",
label: "上一步",
linkType: "url",
target: DASHBOARD_AGENT_ROUTE,
appendTaskId: true,
idParam: "taskId",
idKind: "task",
openMode: "self",
loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true,
sortOrder: 1,
},
{
id: "rwfx-next",
business: "rwfx",
label: "下一步",
linkType: "url",
target: cfg.treemapNextUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true,
sortOrder: 2,
},
{
id: "rwfx-detail3q",
business: "rwfx",
label: "3q详情",
linkType: "url",
target: cfg.detail3qUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
openMode: "blank",
loginParams: [],
appendAuth: false,
authTokenParam: "token",
authNameParam: "username",
enabled: true,
sortOrder: 3,
},
sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 4),
sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 5),
pz("rwfx-purpose", "rwfx", 6),
// 3qfx(三情分析):切换到任务/行动分析
sw("3qfx-switchrw", "3qfx", "切换到任务分析", "rwfx", 0),
sw("3qfx-switchxd", "3qfx", "切换到行动分析", "xdfx", 1),
// xdfx(行动分析):切换到任务/三情分析 + 弹框查看
sw("xdfx-switchrw", "xdfx", "切换到任务分析", "rwfx", 0),
sw("xdfx-switch3q", "xdfx", "切换到三情分析", "3qfx", 1),
pz("xdfx-purpose", "xdfx", 2),
];
}
/**
* 实际生效的按钮列表:后端有任意一条记录时即以后端为准;整张表为空才回退种子默认。
* (与 [[敏感词管理]] 的种子语义一致:空=从未配置→用种子;非空=已配置→后端说了算,
* 哪怕某业务被清空也不回退。)
*/
export function resolveTaskButtons(
stored: TaskButton[],
cfg: TaskDeeplinkConfig,
): TaskButton[] {
return stored.length > 0 ? stored : defaultTaskButtons(cfg);
}
/** 取某业务下、启用的按钮,按 sortOrder 升序。 */
export function buttonsForBusiness(
buttons: TaskButton[],
business: TaskBusiness,
): TaskButton[] {
return buttons
.filter((b) => b.business === business && b.enabled)
.sort((a, b) => a.sortOrder - b.sortOrder);
}
export interface RunTaskButtonCtx {
/** 任务id(xdfx 为 action 接口解析后的真实任务id;其它业务即深链原始 id)。 */
taskId: string;
/** 行动id(xdfx 深链原始 ?taskId=;其它业务通常等于 taskId)。 */
actionId?: string;
navigate: (path: string, opts?: { state?: Record<string, unknown> }) => void;
/** 跳转/打开失败时的提示回调(默认 noop)。 */
onError?: (message: string) => void;
}
/**
* 执行一个跳转按钮:
* - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。
* - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。
* - ``host``:向 ``window.parent`` 发 ``HOST_NAVIGATE``,由宿主系统跳转(仅 iframe 嵌入时生效)。
*/
export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise<void> {
const fail = (m: string) => ctx.onError?.(m);
// purpose(目的树弹框)由组件层处理(需打开 React 弹框),此处不动作。
if (btn.linkType === "purpose") return;
if (btn.linkType === "host") {
const path = btn.target.trim();
if (!isHostNavigatePath(path)) {
fail(`未配置「${btn.label}」的宿主路由`);
return;
}
if (!isEmbeddedInParent()) {
fail("当前不在嵌入页,无法跳转宿主系统");
return;
}
const origin = resolveHostNavigateOrigin({
configured: readRuntimeEnv("VITE_HOST_NAVIGATE_ORIGIN"),
ancestorOrigin: readAncestorOrigin(),
referrer: typeof document !== "undefined" ? document.referrer : null,
selfOrigin: typeof window !== "undefined" ? window.location.origin : null,
});
if (!origin) {
fail("未配置宿主地址(VITE_HOST_NAVIGATE_ORIGIN),无法安全跳转");
return;
}
const queryParams: Record<string, number> = {};
if (btn.appendTaskId) {
const id = parseHostTaskId(ctx.taskId);
if (id === null) {
fail("任务id不是数字,无法跳转宿主系统");
return;
}
queryParams[(btn.idParam || "id").trim() || "id"] = id;
}
const message = buildHostNavigateMessage(path, queryParams);
console.info(`[task-button] 「${btn.label}」宿主路由 →`, message, origin);
window.parent.postMessage(message, origin);
return;
}
if (btn.linkType === "url") {
const base = btn.target.trim();
if (!base) {
fail(`未配置「${btn.label}」的跳转地址`);
return;
}
// 行动分析(xdfx)可选追加行动id;否则用任务id。行动id 缺失时回退任务id。
const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
const param = (btn.idParam || "id").trim() || "id";
let url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
// 追加登录参数(与应用管理一致):运行时从 userInfo 解析每项的值后拼到地址尾部。
const loginParams = btn.loginParams ?? [];
if (loginParams.length > 0) {
const userInfo = readUserInfo();
for (const lp of loginParams) {
const key = (lp.key || "").trim();
if (!key) continue;
url = appendQueryParam(url, key, resolveLoginParamValue(lp, userInfo));
}
}
// 追加登录态:仅 token 登录(存在 token1)才拼 token + 用户名,供外链所在的外部系统识别
// 当前登录用户。非 token 登录读不到 token1 → 整段跳过(不影响跳转本身)。
if (btn.appendAuth) {
const token1 = readLoginToken();
if (token1) {
url = appendQueryParam(url, (btn.authTokenParam || "token").trim() || "token", token1);
const name = readLoginUsername();
if (name) {
url = appendQueryParam(url, (btn.authNameParam || "username").trim() || "username", name);
}
}
}
// 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页):
// self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。
if (base.startsWith("/") && !base.startsWith("//")) {
if (btn.openMode === "blank") {
const full = `${window.location.origin}${window.location.pathname}#${url}`;
// 打印实际打开地址,便于排查登录参数/拼接问题。
console.info(`[task-button] 「${btn.label}」内部路由(新标签) → ${full}`);
window.open(full, "_blank", "noopener,noreferrer");
} else {
console.info(`[task-button] 「${btn.label}」内部路由(同窗) → ${url}`);
ctx.navigate(url);
}
return;
}
// 外链:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。
// 打印最终跳转地址(含追加的 id / 登录参数),便于排查问题。
console.info(
`[task-button] 「${btn.label}」外链(${btn.openMode === "self" ? "当前窗口" : "新窗口"}) → ${url}`,
);
if (btn.openMode === "self") {
window.location.assign(url);
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
return;
}
// business:内部切换到另一业务
const detail = readCopTaskDetail();
if (!detail || !ctx.taskId) {
fail("缺少任务信息,无法跳转");
return;
}
try {
const target = await resolveTaskDeeplinkTarget(btn.target, detail, ctx.taskId);
if (target) ctx.navigate(target.path, { state: target.state });
else fail(`未配置「${btn.label}」对应的业务智能体`);
} catch {
fail("跳转失败,请稍后再试");
}
}