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

@@ -8,6 +8,7 @@ import {
Folder,
FolderOpen,
ListTree,
MousePointerClick,
Settings,
ShieldAlert,
User,
@@ -213,6 +214,14 @@ function SecondaryNavSidebar() {
菜单管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/task-buttons")}
>
<MousePointerClick className="size-4" />
按钮管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
@@ -493,6 +502,12 @@ function LegacyAccordionSidebar({
菜单管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
<MousePointerClick className="size-4" />
按钮管理
</DropdownMenuItem>
)}
{canManageSensitiveWords() && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
<ShieldAlert className="size-4" />
+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;
+8 -1
View File
@@ -38,6 +38,7 @@ export default function AgentChatPage({
chatBasePath,
taskGate,
taskBind,
autoStartWhenPurposeEmpty,
}: {
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
@@ -53,6 +54,8 @@ export default function AgentChatPage({
/** 3qfx / xdfx 任务深链:仅做任务↔对话 1:N 绑定(打 `metadata.taskId` 标签 + 切换会话停旧 run),
* 不展示目的树闸门;新建对话直接把任务开场信息自动发出。与 `taskGate` 互斥使用。 */
taskBind?: boolean;
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
autoStartWhenPurposeEmpty?: boolean;
} = {}) {
const routeBase = useRouteBase();
const { t } = useI18n();
@@ -315,7 +318,11 @@ export default function AgentChatPage({
</header>
{showTaskGate && (
<TaskPurposePanel taskId={taskId} onStart={handleGateStart} />
<TaskPurposePanel
taskId={taskId}
onStart={handleGateStart}
autoStartWhenEmpty={autoStartWhenPurposeEmpty}
/>
)}
{isLandingState ? (
+1 -1
View File
@@ -168,7 +168,7 @@ export default function LoginPage() {
if (cancelled) return;
if (detail) {
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId);
const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId);
if (cancelled) return;
if (target) {
navigate(target.path, { replace: true, state: target.state });
+18 -4
View File
@@ -39,6 +39,7 @@ import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import TaskButtonsPage from "./TaskButtonsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
@@ -135,7 +136,7 @@ export default function StrategyRoutes() {
<Route
path="plan-task-sentiment/agent/:agent_id/chats"
element={
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task-sentiment" lite />
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task-sentiment" business="3qfx" lite />
}
>
<Route
@@ -174,7 +175,7 @@ export default function StrategyRoutes() {
<Route
path="plan-task/plan-task-fx/agent/:agent_id/chats"
element={
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-fx" business="rwfx" />
}
>
<Route
@@ -186,7 +187,10 @@ export default function StrategyRoutes() {
<Route
path=":thread_id"
element={
<TaskFxChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
<TaskFxChat
taskBase="/page/strategy/task-management/plan-task/plan-task-fx"
autoStartWhenPurposeEmpty
/>
}
/>
</Route>
@@ -214,7 +218,7 @@ export default function StrategyRoutes() {
<Route
path="plan-task/plan-task-action/agent/:agent_id/chats"
element={
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-action" lite />
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-action" business="xdfx" lite />
}
>
<Route
@@ -367,6 +371,16 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/task-buttons"
element={
getStoredAuth()?.system_role === "admin" ? (
<TaskButtonsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
+375
View File
@@ -0,0 +1,375 @@
"use client";
/**
* 按钮管理(仅 admin):配置任务工作区(rwfx / 3qfx / xdfx,见 ``TaskFxWorkspace``)
* 左侧栏的可配置跳转按钮——改名称、改跳转方式与地址、启停、排序、增删。
*
* 全局共享、整体替换保存(``PUT /api/task-buttons``)。后端整张表为空时回退代码种子默认
* (rwfx 的 4 个历史按钮),此页首次进入也以种子回填工作副本,便于从当前行为起步。
* 见 [[task-buttons]]。
*/
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import { ArrowDown, ArrowUp, Download, MousePointerClick, Plus, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { getStoredAuth } from "@/core/auth";
import {
DEFAULT_TASK_DEEPLINK_CONFIG,
fetchTaskDeeplinkConfig,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
TASK_BUSINESSES,
TASK_BUSINESS_TARGETS,
defaultTaskButtons,
type TaskBusiness,
type TaskButton,
} from "@/core/auth/task-buttons";
import { useTaskButtonActions, useTaskButtons } from "@/strategy-components/api/task-buttons";
let _seq = 1;
const newId = (b: TaskBusiness) => `${b}-custom-${Date.now()}-${_seq++}`;
/** 归一化用于「脏」判断(按 id 排序后整体比较)。 */
function serialize(rows: TaskButton[]): string {
return JSON.stringify([...rows].sort((a, b) => a.id.localeCompare(b.id)));
}
export default function TaskButtonsPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: serverButtons = [], isLoading } = useTaskButtons();
const { save } = useTaskButtonActions();
const [cfg, setCfg] = useState<TaskDeeplinkConfig>(() => DEFAULT_TASK_DEEPLINK_CONFIG);
useEffect(() => {
let alive = true;
void fetchTaskDeeplinkConfig().then((c) => {
if (alive) setCfg(c);
});
return () => {
alive = false;
};
}, []);
const [rows, setRows] = useState<TaskButton[]>([]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const [tab, setTab] = useState<TaskBusiness>("rwfx");
// 服务端首次/变化时回填工作副本(空表回填种子默认)。以序列化值判定,避免覆盖未保存编辑。
const sourceKey = useMemo(() => serialize(serverButtons), [serverButtons]);
useEffect(() => {
if (sourceKey !== loadedKey) {
setRows(serverButtons.length > 0 ? serverButtons : defaultTaskButtons(cfg));
setLoadedKey(sourceKey);
}
}, [sourceKey, loadedKey, serverButtons, cfg]);
const tabRows = useMemo(
() => rows.filter((r) => r.business === tab).sort((a, b) => a.sortOrder - b.sortOrder),
[rows, tab],
);
const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
const update = (id: string, patch: Partial<TaskButton>) =>
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
const removeRow = (id: string) => setRows((rs) => rs.filter((r) => r.id !== id));
const addRow = () => {
const maxSort = tabRows.reduce((m, r) => Math.max(m, r.sortOrder), -1);
setRows((rs) => [
...rs,
{
id: newId(tab),
business: tab,
label: "新按钮",
linkType: "url",
target: "",
appendTaskId: true,
idParam: "id",
idKind: "task",
enabled: true,
sortOrder: maxSort + 1,
},
]);
};
const moveRow = (id: string, dir: "up" | "down") => {
const list = [...tabRows];
const i = list.findIndex((r) => r.id === id);
const j = dir === "up" ? i - 1 : i + 1;
if (i < 0 || j < 0 || j >= list.length) return;
[list[i], list[j]] = [list[j], list[i]];
const reordered = list.map((r, idx) => ({ ...r, sortOrder: idx }));
setRows((rs) => [...rs.filter((r) => r.business !== tab), ...reordered]);
};
const handleSave = async () => {
const payload = rows.map((r) => ({ ...r, label: r.label.trim() }));
try {
await save.mutateAsync(payload);
toast.success("已保存按钮配置");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
const importDefaults = (business?: TaskBusiness) => {
const have = new Set(rows.map((r) => r.id));
const adds = defaultTaskButtons(cfg)
.filter((d) => !business || d.business === business)
.filter((d) => !have.has(d.id));
if (!adds.length) {
toast.info("没有可导入的默认按钮");
return;
}
setRows((rs) => [...rs, ...adds]);
toast.success(`已导入 ${adds.length} 个默认按钮`);
};
return (
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
<div className="border-border/70 flex items-center justify-between gap-3 border-b px-5 py-4">
<div className="flex items-center gap-2">
<MousePointerClick className="h-5 w-5 text-[var(--color-main,#0062D9)]" />
<div>
<div className="text-foreground text-base font-semibold">按钮管理</div>
<div className="text-muted-foreground text-xs">
配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序)
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => importDefaults()}>
<Download className="h-4 w-4" /> 导入全部默认
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending}>
<Save className="h-4 w-4" /> {save.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
{/* 业务 tab:每个 tab = 一个任务深链页面,明确区分三种页面的按钮 */}
<div className="border-border/70 flex items-stretch gap-2 border-b px-5 pt-3">
{TASK_BUSINESSES.map((b) => {
const count = rows.filter((r) => r.business === b.code).length;
const active = tab === b.code;
return (
<button
key={b.code}
type="button"
onClick={() => setTab(b.code)}
className={cn(
"flex min-w-[8.5rem] flex-col gap-0.5 rounded-t-lg border border-b-0 px-4 py-2 text-left transition-colors",
active
? "border-[var(--color-main,#0062D9)] bg-[var(--color-main,#0062D9)] text-white"
: "border-border/70 text-muted-foreground hover:bg-muted bg-transparent",
)}
>
<span className="flex items-center gap-1.5 text-sm font-semibold">
{b.name}
<span
className={cn(
"rounded px-1.5 text-[11px] tabular-nums",
active ? "bg-white/20" : "bg-muted-foreground/15",
)}
>
{count}
</span>
</span>
<span className={cn("text-[11px]", active ? "text-white/80" : "opacity-70")}>
?goPath={b.goPath}
</span>
</button>
);
})}
</div>
{/* 当前页面说明 */}
{(() => {
const meta = TASK_BUSINESSES.find((b) => b.code === tab)!;
return (
<div className="border-border/70 bg-muted/40 text-muted-foreground border-b px-5 py-2 text-xs">
正在编辑「<span className="text-foreground font-medium">{meta.name}</span>
」页面的按钮 · {meta.desc} 下方按钮会显示在该页面底部的「快捷跳转」栏,从上到下对应栏内从左到右的顺序。
</div>
);
})()}
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{isLoading ? (
<div className="text-muted-foreground text-sm">加载中…</div>
) : tabRows.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-md border border-dashed p-6 text-center text-sm">
该页面暂无按钮。可「导入该页面默认按钮」一键填充,或点下方「添加按钮」自定义。
<Button variant="outline" size="sm" onClick={() => importDefaults(tab)}>
<Download className="h-4 w-4" /> 导入该页面默认按钮
</Button>
</div>
) : (
<div className="flex flex-col gap-3">
{tabRows.map((r, idx) => (
<div
key={r.id}
className="border-border/70 grid grid-cols-[1fr_auto] items-start gap-3 rounded-lg border bg-card/60 p-3"
>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<label className="text-muted-foreground w-16 shrink-0 text-xs">名称</label>
<Input
value={r.label}
onChange={(e) => update(r.id, { label: e.target.value })}
placeholder="按钮名称"
className="h-8 max-w-xs"
/>
<label className="text-muted-foreground ml-2 shrink-0 text-xs">类型</label>
<Select
value={r.linkType}
onValueChange={(v) => update(r.id, { linkType: v as TaskButton["linkType"] })}
>
<SelectTrigger className="h-8 w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url">外部链接</SelectItem>
<SelectItem value="business">切换业务</SelectItem>
<SelectItem value="purpose">目的树弹框</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="text-muted-foreground w-16 shrink-0 text-xs">目标</label>
{r.linkType === "purpose" ? (
<span className="text-muted-foreground text-xs">
点击弹出当前任务的「任务目的」表格,无需配置目标。
</span>
) : r.linkType === "url" ? (
<>
<Input
value={r.target}
onChange={(e) => update(r.id, { target: e.target.value })}
placeholder="https://…(跳转地址)"
className="h-8 flex-1 min-w-[16rem]"
/>
<label className="text-muted-foreground ml-2 flex shrink-0 items-center gap-1.5 text-xs">
<Switch
checked={r.appendTaskId}
onCheckedChange={(v) => update(r.id, { appendTaskId: v })}
/>
追加参数
</label>
{r.appendTaskId && (
<div className="flex shrink-0 items-center gap-1 text-xs">
<span className="text-muted-foreground">?</span>
<Input
value={r.idParam}
onChange={(e) => update(r.id, { idParam: e.target.value })}
placeholder="id"
className="h-8 w-24"
title="查询参数名(如 id / task_id)"
/>
<span className="text-muted-foreground">=</span>
{/* 行动分析(xdfx):行动id 与任务id 不同,可选追加哪种;其它业务固定任务id */}
{tab === "xdfx" ? (
<Select
value={r.idKind}
onValueChange={(v) => update(r.id, { idKind: v as TaskButton["idKind"] })}
>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">任务id</SelectItem>
<SelectItem value="action">行动id</SelectItem>
</SelectContent>
</Select>
) : (
<span className="text-muted-foreground">任务id</span>
)}
</div>
)}
</>
) : (
<Select value={r.target} onValueChange={(v) => update(r.id, { target: v })}>
<SelectTrigger className="h-8 w-48">
<SelectValue placeholder="选择目标业务" />
</SelectTrigger>
<SelectContent>
{TASK_BUSINESS_TARGETS.map((t) => (
<SelectItem key={t.code} value={t.code}>
{t.label}({t.code})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="flex items-center gap-1">
<label className="text-muted-foreground mr-1 flex items-center gap-1.5 text-xs">
<Switch
checked={r.enabled}
onCheckedChange={(v) => update(r.id, { enabled: v })}
/>
启用
</label>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={idx === 0}
onClick={() => moveRow(r.id, "up")}
title="上移"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={idx === tabRows.length - 1}
onClick={() => moveRow(r.id, "down")}
title="下移"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-8 w-8"
onClick={() => removeRow(r.id)}
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
<Button variant="outline" size="sm" className="mt-4" onClick={addRow}>
<Plus className="h-4 w-4" /> 添加按钮
</Button>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
/**
* 任务工作区可配置跳转按钮(按钮管理)API 客户端 + React Query hooks。
*
* 后端:`/api/task-buttons`(见 app/gateway/routers/task_buttons.py)。
* 全局共享、无用户隔离;读对所有登录用户开放(工作区要用),写仅 admin(整体替换)。
* snake_case ⇄ camelCase 在此边界收敛。见 [[task-buttons]]。
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type {
TaskBusiness,
TaskButton,
TaskButtonIdKind,
TaskButtonLinkType,
} from "@/core/auth/task-buttons";
const BASE = () => `${getBackendBaseURL()}/api/task-buttons`;
interface ServerButton {
id: string;
business: string;
label: string;
link_type: string;
target: string;
append_task_id: boolean;
id_param: string;
id_kind: string;
enabled: boolean;
sort_order: number;
}
function toButton(s: ServerButton): TaskButton {
return {
id: s.id,
business: (s.business as TaskBusiness) ?? "rwfx",
label: s.label ?? "",
linkType: (s.link_type as TaskButtonLinkType) ?? "url",
target: s.target ?? "",
appendTaskId: s.append_task_id ?? true,
idParam: s.id_param || "id",
idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
enabled: s.enabled ?? true,
sortOrder: s.sort_order ?? 0,
};
}
function toBody(b: TaskButton): ServerButton {
return {
id: b.id,
business: b.business,
label: b.label,
link_type: b.linkType,
target: b.target,
append_task_id: b.appendTaskId,
id_param: b.idParam,
id_kind: b.idKind,
enabled: b.enabled,
sort_order: b.sortOrder,
};
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
export async function listTaskButtons(): Promise<TaskButton[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ buttons: ServerButton[] }>(res, "list task buttons");
return (data.buttons ?? []).map(toButton);
}
export async function replaceTaskButtons(buttons: TaskButton[]): Promise<TaskButton[]> {
const res = await apiFetch(BASE(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ buttons: buttons.map(toBody) }),
});
const data = await readJson<{ buttons: ServerButton[] }>(res, "save task buttons");
return (data.buttons ?? []).map(toButton);
}
// ── React Query hooks(工作区 + 管理页共用缓存) ────────────────────────────
const TASK_BUTTONS_KEY = "task-buttons";
export function useTaskButtons() {
return useQuery({
queryKey: [TASK_BUTTONS_KEY],
queryFn: listTaskButtons,
refetchOnWindowFocus: false,
staleTime: 60_000,
});
}
export function useTaskButtonActions() {
const queryClient = useQueryClient();
return {
save: useMutation({
mutationFn: replaceTaskButtons,
onSuccess: () => queryClient.invalidateQueries({ queryKey: [TASK_BUTTONS_KEY] }),
}),
};
}
@@ -16,7 +16,7 @@
import { useEffect, useMemo, useState } from "react";
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
import { ArrowRight, ExternalLink, Loader2, Maximize2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -28,20 +28,27 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import {
buildTreemapEditUrl,
buildTreemapNextUrl,
deleteTaskPurpose,
readTaskContext,
} from "@/core/auth/task-purpose";
import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
DEFAULT_TASK_DEEPLINK_CONFIG,
fetchTaskDeeplinkConfig,
type TaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import {
buttonsForBusiness,
resolveTaskButtons,
runTaskButton,
type TaskBusiness,
} from "@/core/auth/task-buttons";
import { useTaskButtons } from "@/strategy-components/api/task-buttons";
import { useTaskThreads } from "@/core/threads/use-task-threads";
import { titleOfThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import AgentChatPage from "@/pages/AgentChatPage";
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposePanel";
function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } {
const { agent_id } = useParams<{ agent_id: string }>();
@@ -90,8 +97,72 @@ function parseThreadId(pathname: string, chatsBase: string): string | null {
}
/**
* 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」+(rwfx)跳转三情分析;下方对话列表(1:N)。
* ``lite``(3qfx/xdfx)隐藏跳转按钮与底部任务操作面板。
* 底部「快捷跳转」栏:横跨整个工作区底部,按配置顺序渲染该业务启用的跳转按钮(按钮管理里排序)。
* 后端为准,整张表为空才回退种子默认;地址依赖实时深链配置,故把配置拉进 state。见 [[task-buttons]]。
*/
function TaskJumpBar({ business, taskId }: { business: TaskBusiness; taskId: string }) {
const navigate = useNavigate();
const [cfg, setCfg] = useState<TaskDeeplinkConfig>(() => DEFAULT_TASK_DEEPLINK_CONFIG);
useEffect(() => {
let alive = true;
void fetchTaskDeeplinkConfig().then((c) => {
if (alive) setCfg(c);
});
return () => {
alive = false;
};
}, []);
const { data: storedButtons = [] } = useTaskButtons();
const jumpButtons = useMemo(
() => buttonsForBusiness(resolveTaskButtons(storedButtons, cfg), business),
[storedButtons, cfg, business],
);
// 目的树弹框(purpose 类型按钮)由组件层打开。
const [purposeOpen, setPurposeOpen] = useState(false);
if (jumpButtons.length === 0) return null;
const handleRun = (btn: (typeof jumpButtons)[number]) => {
if (btn.linkType === "purpose") {
setPurposeOpen(true);
return;
}
void runTaskButton(btn, {
taskId,
actionId: readTaskContext()?.actionId,
navigate,
onError: (m) => toast.error(m),
});
};
return (
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
{jumpButtons.map((btn) => (
<Button
key={btn.id}
className="h-11 gap-2 border-transparent bg-[#cf1322] px-6 text-base text-white shadow-sm hover:bg-[#a30f1b]"
onClick={() => handleRun(btn)}
title={btn.label}
>
<span className="truncate">{btn.label}</span>
{btn.linkType === "url" ? (
<ExternalLink className="h-5 w-5 shrink-0" />
) : btn.linkType === "purpose" ? (
<Maximize2 className="h-5 w-5 shrink-0" />
) : (
<ArrowRight className="h-5 w-5 shrink-0" />
)}
</Button>
))}
<TaskPurposeDialog taskId={taskId} open={purposeOpen} onOpenChange={setPurposeOpen} />
</div>
);
}
/**
* 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」;下方对话列表(1:N)。
* 跳转按钮已移到工作区底部「快捷跳转」栏(见 ``TaskJumpBar``)。
*/
function TaskConversationsList({
taskId,
@@ -109,22 +180,6 @@ function TaskConversationsList({
const { threads, isLoading } = useTaskThreads(taskId, agentId);
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
// rwfx:带当前任务(同一 taskId)跳到三情分析工作区。复用深链解析(会按 3Q 业务码重绑任务上下文)。
const handleJumpToSentiment = async () => {
const detail = readCopTaskDetail();
if (!detail || !taskId) {
toast.error("缺少任务信息,无法跳转三情分析");
return;
}
try {
const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId);
if (target) navigate(target.path, { state: target.state });
else toast.error("未配置三情分析智能体");
} catch {
toast.error("跳转失败,请稍后再试");
}
};
return (
<div className="bg-background/85 border-border/70 flex w-64 shrink-0 flex-col border-r shadow-[12px_0_30px_-30px_rgba(15,23,42,0.45)] backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/82">
<div className="border-border/70 border-b p-3 dark:border-[#1b477e]/70">
@@ -144,17 +199,7 @@ function TaskConversationsList({
>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
{!lite && (
<Button
size="sm"
variant="outline"
className="mt-2 h-9 w-full border-[var(--card-border)] bg-background/80 text-[var(--color-main)] hover:bg-[var(--bg-color)] hover:text-[var(--color-main)] dark:border-[#2b8ffe]/50 dark:bg-[#0b2546]/80 dark:text-[#bde9ff] dark:hover:bg-[#10375f]"
onClick={handleJumpToSentiment}
title="带当前任务跳转到三情分析"
>
切换到三情分析 <ArrowRight className="h-4 w-4" />
</Button>
)}
{!lite && <TaskClearButton taskId={taskId} />}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
@@ -191,24 +236,18 @@ function TaskConversationsList({
))
)}
</div>
{!lite && <TaskActionsPanel taskId={taskId} />}
</div>
);
}
/**
* 左侧任务栏底部操作区:编辑 / 下一步 / 清除任务记录。
* 跟随任务对话面板布局,不再覆盖右侧聊天输入框。
* 「清除记录」按钮(rwfx):放在「新建对话」下面,仅调 delete 接口清空 B 系统目的策划数据
* (聊天对话保留)。带二次确认弹框。
*/
function TaskActionsPanel({ taskId }: { taskId: string }) {
function TaskClearButton({ taskId }: { taskId: string }) {
const [clearOpen, setClearOpen] = useState(false);
const [clearing, setClearing] = useState(false);
const openExternal = (url: string, label: string) => {
if (url) window.open(url, "_blank", "noopener,noreferrer");
else toast.error(`未配置${label}地址`);
};
const handleClear = async () => {
setClearing(true);
try {
@@ -225,44 +264,16 @@ function TaskActionsPanel({ taskId }: { taskId: string }) {
return (
<>
<div className="border-border/70 shrink-0 border-t p-3 dark:border-[#1b477e]/70">
<div className="rounded-md border border-[var(--card-border)] bg-background/80 p-2 shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#0b2546]/72">
<div className="text-muted-foreground mb-2 px-1 text-xs font-medium">
任务操作
</div>
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
className="h-8 border-[var(--card-border)] bg-background/80 text-[var(--color-main)] hover:bg-[var(--bg-color)] hover:text-[var(--color-main)] dark:border-[#2b8ffe]/50 dark:bg-[#0b2546]/80 dark:text-[#bde9ff] dark:hover:bg-[#10375f]"
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
disabled={!taskId}
title="编辑任务"
>
<ExternalLink className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
className="h-8 bg-[var(--color-main)] text-white shadow-[0_8px_18px_-12px_var(--color-main)] hover:bg-[var(--brand-950)]"
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
disabled={!taskId}
title="进入下一步"
>
下一步 <ArrowRight className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="col-span-2 h-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35"
onClick={() => setClearOpen(true)}
disabled={!taskId}
title="清除任务记录"
>
<Trash2 className="h-3.5 w-3.5" /> 清除记录
</Button>
</div>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive dark:text-[#ccd2da] dark:hover:bg-[#5c0011]/35 mt-2 h-9 w-full"
onClick={() => setClearOpen(true)}
disabled={!taskId}
title="清除任务记录"
>
<Trash2 className="h-3.5 w-3.5" /> 清除记录
</Button>
<Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}>
<DialogContent>
@@ -290,18 +301,30 @@ function TaskActionsPanel({ taskId }: { taskId: string }) {
* 任务工作区布局:左列表 + 右 Outlet。
* ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。
*/
export function TaskFxWorkspace({ taskBase, lite }: { taskBase: string; lite?: boolean }) {
export function TaskFxWorkspace({
taskBase,
business,
lite,
}: {
taskBase: string;
business: TaskBusiness;
lite?: boolean;
}) {
const { taskId, agentId, chatsBase } = useChatsBase(taskBase);
// 预拉配置,保证(rwfx)「编辑/下一步」的同步 URL 构造能读到缓存。
useEffect(() => {
void fetchTaskDeeplinkConfig();
}, []);
return (
<div className="flex size-full min-h-0 overflow-hidden">
<TaskConversationsList taskId={taskId} agentId={agentId} chatsBase={chatsBase} lite={lite} />
<div className="relative min-h-0 min-w-0 flex-1">
<Outlet />
<div className="flex size-full min-h-0 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden">
<TaskConversationsList
taskId={taskId}
agentId={agentId}
chatsBase={chatsBase}
lite={lite}
/>
<div className="relative min-h-0 min-w-0 flex-1">
<Outlet />
</div>
</div>
<TaskJumpBar business={business} taskId={taskId} />
</div>
);
}
@@ -328,12 +351,25 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
);
}
/** 右侧对话宿主(rwfx):AgentChatPage taskGate → 新建对话顶部展示目的树闸门面板 + 老对话停止。 */
export function TaskFxChat({ taskBase }: { taskBase: string }) {
/**
* 右侧对话宿主(rwfx / xdfx):AgentChatPage taskGate → 新建对话顶部展示目的树闸门面板 + 老对话停止。
* ``autoStartWhenPurposeEmpty``(rwfx):查询任务目的为空数据时自动开始第一次问答。
*/
export function TaskFxChat({
taskBase,
autoStartWhenPurposeEmpty,
}: {
taskBase: string;
autoStartWhenPurposeEmpty?: boolean;
}) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskGate />
<AgentChatPage
chatBasePath={`${taskBase}/agent/${agent_id}/chats`}
taskGate
autoStartWhenPurposeEmpty={autoStartWhenPurposeEmpty}
/>
</ChatRuntime>
);
}
@@ -10,8 +10,8 @@
* onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
*/
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Loader2, Maximize2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronRight, Loader2, Maximize2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -24,7 +24,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import {
buildDetail3qUrl,
deleteTaskPurpose,
fetchTaskPurposeDetail,
flattenPurposeTree,
@@ -34,7 +33,7 @@ import {
import { cn } from "@/lib/utils";
/** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */
function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) {
export function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) {
const rows = useMemo(() => flattenPurposeTree(tree), [tree]);
const headers = useMemo(() => {
const maxDepth = rows.reduce((m, r) => Math.max(m, r.length), 0);
@@ -91,26 +90,86 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?
);
}
/**
* 独立的「任务目的弹框」:按需拉取目的树并放大展示。供底部「快捷跳转」栏的 ``purpose`` 类型
* 按钮复用(任意页面可配置「弹框查看」)。打开时才拉取。
*/
export function TaskPurposeDialog({
taskId,
open,
onOpenChange,
}: {
taskId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<TaskPurposeResult | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
fetchTaskPurposeDetail(taskId)
.then((r) => !cancelled && setResult(r))
.catch(() => !cancelled && setResult({ tree: [], hasData: false }))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [open, taskId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[90vw] !max-w-[90vw] sm:!max-w-[90vw]">
<DialogHeader>
<DialogTitle>任务目的(任务 {taskId})</DialogTitle>
</DialogHeader>
{loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-6 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : (
<PurposeTable tree={result?.tree ?? []} className="max-h-[70vh] rounded border border-border/60" />
)}
</DialogContent>
</Dialog>
);
}
export function TaskPurposePanel({
taskId,
onStart,
autoStartWhenEmpty,
}: {
taskId: string;
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */
onStart: () => void;
/** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */
autoStartWhenEmpty?: boolean;
}) {
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<TaskPurposeResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [working, setWorking] = useState(false);
const [zoomOpen, setZoomOpen] = useState(false);
// 任务目的默认收起(不展开),点标题切换。
const [collapsed, setCollapsed] = useState(true);
// rwfx 空数据自动开始:防止重复触发。
const autoStartedRef = useRef(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchTaskPurposeDetail(taskId)
.then((r) => {
if (!cancelled) setResult(r);
if (cancelled) return;
setResult(r);
// 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。
if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) {
autoStartedRef.current = true;
onStart();
}
})
.catch(() => {
if (!cancelled) setResult({ tree: [], hasData: false });
@@ -121,6 +180,8 @@ export function TaskPurposePanel({
return () => {
cancelled = true;
};
// onStart/autoStartWhenEmpty 在一次新建会话内稳定;仅按 taskId 重新拉取。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);
const handleStart = () => {
@@ -152,37 +213,39 @@ export function TaskPurposePanel({
return (
<div className="border-border/70 bg-card/60 shrink-0 border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">任务目的(任务 {taskId})</span>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className="flex items-center gap-1 text-sm font-medium hover:text-[var(--color-main)]"
title={collapsed ? "展开任务目的" : "收起任务目的"}
>
{collapsed ? (
<ChevronRight className="h-4 w-4 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 shrink-0" />
)}
任务目的(任务 {taskId})
</button>
<div className="flex items-center gap-2">
{hasTree && (
<Button size="sm" variant="outline" onClick={() => setZoomOpen(true)}>
<Maximize2 className="h-3.5 w-3.5" /> 弹框查看
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
const url = buildDetail3qUrl(taskId);
if (url) window.open(url, "_blank", "noopener,noreferrer");
else toast.error("未配置3q详情地址");
}}
>
<ExternalLink className="h-3.5 w-3.5" /> 3q详情
</Button>
<Button size="sm" onClick={handleStart} disabled={loading || working}>
{result?.hasData ? "覆盖并开始问答" : "开始问答"}
</Button>
</div>
</div>
{loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : (
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
)}
{!collapsed &&
(loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : (
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
))}
{/* 弹框查看:放大显示完整表格 */}
<Dialog open={zoomOpen} onOpenChange={setZoomOpen}>