feat: 任务工作区可配置跳转按钮(按钮管理) + 底部快捷跳转栏 + 目的树收起/空自动问答

- 新增「按钮管理」admin 页(/admin/task-buttons):按业务(rwfx/3qfx/xdfx)配置跳转按钮,
  支持改名称/类型(外链·切换业务·目的树弹框)/目标/追加参数名(id·task_id)/任务id·行动id/启停/排序
- 跳转按钮从左侧栏移到工作区底部「快捷跳转」栏:红底白字、靠右、变大;rwfx 历史 4 按钮纳入配置
- 后端新增全局共享 store /api/task-buttons (table task_buttons,admin 整体替换)
- xdfx 行动id/任务id:登录暂存原始行动id,按钮可选追加任一;id 参数名可自定义
- rwfx 清除记录移到「新建对话」下、去掉任务操作框;查询任务目的为空时自动发第一次问答
- 目的树面板默认收起;「弹框查看」抽成可复用 TaskPurposeDialog,可作为按钮配置到任意页面
- 文档(根/后端 CLAUDE.md) + 后端测试 tests/test_task_buttons.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-18 15:41:05 +08:00
1 parent ecb79ce49c
commit 16afa9bcbb
22 files changed
+1341 -130

No files matched your search

+246
View File
@@ -0,0 +1,246 @@
/**
* 任务工作区(任务深链 rwfx / 3qfx / xdfx,见 ``TaskFxWorkspace``)左侧栏的
* **可配置跳转按钮**。
*
* 数据源优先级:后端 ``/api/task-buttons``(全局共享、admin 整体替换)。**仅当整张表
* 为空(全新部署、从未保存过)**时回退到代码种子默认(``defaultTaskButtons``,从实时
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮两类(``linkType``):``url`` 外链(``window.open``,可选追加 ``?id=任务id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
*/
import {
appendQueryParam,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
readCopTaskDetail,
resolveTaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
/** 任务工作区业务码(与路由 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``=弹出任务目的树表格(无需 target)。 */
export type TaskButtonLinkType = "url" | "business" | "purpose";
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
export type TaskButtonIdKind = "task" | "action";
/** 一个可配置跳转按钮。 */
export interface TaskButton {
id: string;
business: TaskBusiness;
label: string;
linkType: TaskButtonLinkType;
/** ``url``=完整地址;``business``=目标业务码(如 ``3qfx``)。 */
target: string;
/** ``url`` 类型是否追加 id 查询参数。 */
appendTaskId: boolean;
/** 追加的查询参数名(如 ``id`` / ``task_id``);空则用 ``id``。 */
idParam: string;
/**
* ``url`` 追加 id 时取任务id 还是行动id。仅 xdfx(行动分析)二者不同:深链原始 ``?taskId=``
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
*/
idKind: TaskButtonIdKind;
enabled: boolean;
sortOrder: number;
}
/** 内部切换可选的目标业务(``business`` 类型按钮的下拉项)。 */
export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
{ code: "3qfx", label: "三情分析" },
{ code: "rwfx", label: "任务分析" },
{ code: "xdfx", label: "行动分析" },
];
/**
* 代码种子默认按钮(仅在后端整张表为空时使用):
* - rwfx:历史 4 个按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+ 切换到三情/行动分析;
* - 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",
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",
enabled: true,
sortOrder,
});
return [
// rwfx(任务分析):外链 + 切换
{
id: "rwfx-edit",
business: "rwfx",
label: "编辑",
linkType: "url",
target: cfg.treemapEditUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder: 0,
},
{
id: "rwfx-next",
business: "rwfx",
label: "下一步",
linkType: "url",
target: cfg.treemapNextUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder: 1,
},
{
id: "rwfx-detail3q",
business: "rwfx",
label: "3q详情",
linkType: "url",
target: cfg.detail3qUrl,
appendTaskId: true,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder: 2,
},
sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 3),
sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 4),
pz("rwfx-purpose", "rwfx", 5),
// 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)。
*/
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 === "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";
const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
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("跳转失败,请稍后再试");
}
}
+11 -3
View File
@@ -174,13 +174,16 @@ function buildOpeningText(detail: CopTaskDetail, taskId: string): string {
* ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。
* 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。
*
* @param taskId 深链 URL 的原始 ``?taskId=``(外部任务 id,用于按任务隔离草稿;与
* consumer 返回的 detail.id 区分——后者不保证等于外部任务 id)。
* @param taskId 真实任务 id(xdfx 已由 action 接口解析;其它业务即深链原始 ?taskId=)。
* 用于按任务隔离草稿/对话;与 consumer 返回的 detail.id 区分。
* @param rawId 深链 URL 的原始 ``?taskId=``(xdfx 为「行动id」)。供需追加行动id 的按钮用;
* 省略时回退 taskId。
*/
export async function resolveTaskDeeplinkTarget(
goPath: string,
detail: CopTaskDetail,
taskId: string,
rawId?: string,
): Promise<TaskDeeplinkTarget | null> {
const parsed = parseGoPath(goPath);
if (!parsed) return null;
@@ -220,6 +223,11 @@ export async function resolveTaskDeeplinkTarget(
// 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定
// 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表,
// 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。
persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail, taskId) });
persistTaskContext({
taskId,
agentId,
openingText: buildOpeningText(detail, taskId),
actionId: rawId || taskId,
});
return { path: `${taskBase}/agent/${agentId}/chats` };
}
@@ -33,6 +33,8 @@ export interface TaskContext {
agentId: string;
/** 自动发出的开场信息(任务名称/内容)。 */
openingText: string;
/** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */
actionId?: string;
}
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
@@ -216,6 +218,7 @@ export function readTaskContext(): TaskContext | null {
taskId: String(parsed.taskId),
agentId: String(parsed.agentId),
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
};
} catch {
return null;