为深度研究报告结构增加检索方向与技能来源、对话式智能新增页与详情排版;澄清中间件在 after_model 可靠结束本轮,避免弱模型出卡后继续生成;任务按钮补充 host 跳转与相关深链能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
442 lines
17 KiB
TypeScript
442 lines
17 KiB
TypeScript
/**
|
||
* 任务工作区(任务深链 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("跳转失败,请稍后再试");
|
||
}
|
||
}
|