feat: 任务目的树表格化展示 + 切换会话停止老对话服务端 run

- task-purpose: normalizeNode 容错(未知 *Dto 兜底/文本字段改名兜底);新增 flattenPurposeTree 拍平目的树
- TaskPurposePanel: 目的树改表格展示(列数自适应),支持弹框放大查看
- threads/hooks: 新增 stopThreadRun,真正取消服务端 run(非仅断开本地 SSE)
- AgentChatPage: taskGate 下切换/卸载会话时停掉老对话服务端 run
- TaskFxWorkspace: 操作按钮(编辑/下一步/清除)改右下角常驻红色浮层

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-17 16:42:05 +08:00
1 parent ba0ec20532
commit a2bb8c80a2
5 files changed
+273 -106

No files matched your search

+54 -12
View File
@@ -48,28 +48,70 @@ const DTO_FIELD_MAP: Record<string, { label: string; textKey: string }> = {
narrationDto: { label: "叙事", textKey: "narration" }, narrationDto: { label: "叙事", textKey: "narration" },
}; };
// 取节点主文本时跳过的字段(id/时间/标记/理由等非展示文本),保证容错。
const SKIP_TEXT_KEY = /(^id$|Id$|Time$|Flag$|Type$|Level$|recReason|createBy|updateBy|^mid$)/i;
/** 从一个 dto 里取展示文本:优先 preferredKey,否则取第一个像「内容」的字符串字段。 */
function pickDtoText(dto: Record<string, unknown>, preferredKey?: string): string {
if (preferredKey && typeof dto[preferredKey] === "string" && (dto[preferredKey] as string).trim()) {
return dto[preferredKey] as string;
}
for (const [k, v] of Object.entries(dto)) {
if (typeof v === "string" && v.trim() && !SKIP_TEXT_KEY.test(k)) return v;
}
return "";
}
/**
* 把一层节点归一化为 {label,text,reason,children}。**容错**:
* - 优先匹配已知 dto(目的/行为体/...);未知 ``*Dto`` 字段也兜底(标签取字段名去 Dto);
* - 文本字段改名也能取到(pickDtoText 兜底);
* - 完全不认识的结构仍递归 childrenList,避免整棵树丢失。
*/
function normalizeNode(raw: unknown): PurposeTreeNode | null { function normalizeNode(raw: unknown): PurposeTreeNode | null {
if (!raw || typeof raw !== "object") return null; if (!raw || typeof raw !== "object") return null;
const obj = raw as Record<string, unknown>; const obj = raw as Record<string, unknown>;
// 找出本节点携带的 dto 字段(目的/行为体/...)。 const isObj = (k: string) => obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k]);
const dtoKey = Object.keys(DTO_FIELD_MAP).find( // 先找已知 dto,再兜底任意 `*Dto` 字段。
(k) => obj[k] && typeof obj[k] === "object", let dtoKey = Object.keys(DTO_FIELD_MAP).find(isObj);
); if (!dtoKey) dtoKey = Object.keys(obj).find((k) => /Dto$/.test(k) && isObj(k));
if (!dtoKey) return null;
const { label, textKey } = DTO_FIELD_MAP[dtoKey];
const dto = obj[dtoKey] as Record<string, unknown>;
const text = typeof dto[textKey] === "string" ? (dto[textKey] as string) : "";
const reason =
typeof dto.recReason === "string" && dto.recReason.trim()
? (dto.recReason as string)
: undefined;
const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : []; const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : [];
const children = childrenRaw const children = childrenRaw
.map(normalizeNode) .map(normalizeNode)
.filter((n): n is PurposeTreeNode => n !== null); .filter((n): n is PurposeTreeNode => n !== null);
if (!dtoKey) {
// 不认识的结构:仅当有子节点时保留为占位节点,否则丢弃。
return children.length ? { label: "节点", text: "", children } : null;
}
const mapped = DTO_FIELD_MAP[dtoKey];
const dto = obj[dtoKey] as Record<string, unknown>;
const label = mapped?.label ?? dtoKey.replace(/Dto$/, "");
const text = pickDtoText(dto, mapped?.textKey);
const reason =
typeof dto.recReason === "string" && (dto.recReason as string).trim()
? (dto.recReason as string)
: undefined;
return { label, text, reason, children }; return { label, text, reason, children };
} }
/** 表格行:根→叶一条路径上的各层单元格(层级数可变,做了容错)。 */
export type PurposeFlatCell = { label: string; text: string; reason?: string };
/** 把目的树拍平成「一行一条叶子路径」,供表格展示(层级缺失/变动都不崩)。 */
export function flattenPurposeTree(nodes: PurposeTreeNode[]): PurposeFlatCell[][] {
const rows: PurposeFlatCell[][] = [];
const walk = (node: PurposeTreeNode, trail: PurposeFlatCell[]) => {
const next = [...trail, { label: node.label, text: node.text, reason: node.reason }];
if (!node.children || node.children.length === 0) rows.push(next);
else node.children.forEach((c) => walk(c, next));
};
nodes.forEach((n) => walk(n, []));
return rows;
}
/** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */ /** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */
function bodyToTree(body: unknown): PurposeTreeNode[] { function bodyToTree(body: unknown): PurposeTreeNode[] {
const data = const data =
+33
View File
@@ -121,6 +121,39 @@ function getRunMetadataStorage(): {
}; };
} }
/**
* 显式停止某会话「服务端正在跑」的 run。
*
* 注意:useStream / `thread.stop()` 只中断本地 SSE 监听,服务端 run 仍会继续跑。
* 任务流程要求「开启新对话时老对话要停止」,所以这里读出 SDK 存在 sessionStorage
* (``lg:stream:{threadId}``)里的 run id,调用 ``runs.cancel`` 真正取消后端 run,
* 再清掉本地 reconnect 记录。已结束 / 不存在的 run 取消失败时静默忽略。
*/
export async function stopThreadRun(
threadId: string | null | undefined,
): Promise<void> {
if (!threadId || typeof window === "undefined") return;
let runId: string | null = null;
try {
runId = normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
);
} catch {
runId = null;
}
if (!runId) return;
try {
await getAPIClient().runs.cancel(threadId, runId);
} catch {
/* 已结束 / 不存在 / 网络错误等忽略 */
}
try {
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
} catch {
/* noop */
}
}
function getStreamErrorMessage(error: unknown): string { function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) { if (typeof error === "string" && error.trim()) {
return error; return error;
+17 -1
View File
@@ -26,7 +26,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { readTaskContext } from "@/core/auth/task-purpose"; import { readTaskContext } from "@/core/auth/task-purpose";
@@ -161,6 +161,22 @@ export default function AgentChatPage({
[sendMessage, threadId, agent_id], [sendMessage, threadId, agent_id],
); );
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
// 仅 taskGate(rwfx 任务工作区)启用——普通问答页保留后台续跑行为不变。
const prevTaskThreadRef = useRef<string | null>(null);
useEffect(() => {
if (!taskGate) return;
const prev = prevTaskThreadRef.current;
if (prev && prev !== threadId) void stopThreadRun(prev);
prevTaskThreadRef.current = threadId ?? null;
}, [taskGate, threadId]);
useEffect(() => {
// 离开任务问答页(卸载)时也停掉当前会话的服务端 run。
return () => {
if (taskGate) void stopThreadRun(prevTaskThreadRef.current);
};
}, [taskGate]);
// 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则 // 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则
// 仅切到对话态(隐藏面板),让用户手动输入。 // 仅切到对话态(隐藏面板),让用户手动输入。
const handleGateStart = useCallback(() => { const handleGateStart = useCallback(() => {
@@ -1,19 +1,19 @@
/** /**
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。 * 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
* *
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+ 任务级操作 * 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」;
* (新建对话 / 编辑 / 下一步 / 清除任务记录); * 右下角 = 常驻浮层红色按钮(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走;
* 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。 * 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
* *
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。 * - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
* - 新建对话 = 直接跳入:navigate 到 chats/new,AgentChatPage 闸门态展示目的树面板。 * - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
* - 清除任务记录:仅调 delete 接口(聊天对话保留)。 * - 清除任务记录:仅调 delete 接口(聊天对话保留)。
* - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。 * - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
* 见 [[task-purpose]] / [[task-deeplink-config]]。 * 见 [[task-purpose]] / [[task-deeplink-config]]。
*/ */
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Outlet, useNavigate, useParams } from "react-router-dom"; import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react"; import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -46,11 +46,59 @@ function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } {
return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` }; return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` };
} }
/** 左侧任务对话列表 + 任务级操作。 */ /** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */
function parseThreadId(pathname: string, chatsBase: string): string | null {
if (!pathname.startsWith(chatsBase)) return null;
const seg = pathname.slice(chatsBase.length).replace(/^\/+/, "").split("/")[0];
return seg && seg !== "new" ? seg : null;
}
/** 左侧任务对话列表 +「新建对话」。 */
function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) { function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) {
const { thread_id } = useParams<{ thread_id: string }>(); const { pathname } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { threads, isLoading } = useTaskThreads(taskId); const { threads, isLoading } = useTaskThreads(taskId);
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
return (
<div className="border-border/70 bg-card/40 flex w-60 shrink-0 flex-col border-r">
<div className="border-border/70 border-b p-2">
<Button size="sm" className="w-full" onClick={() => navigate(`${chatsBase}/new`)}>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{isLoading ? (
<div className="text-muted-foreground p-2 text-sm">加载中…</div>
) : threads.length === 0 ? (
<div className="text-muted-foreground p-2 text-sm">暂无对话</div>
) : (
threads.map((thread) => (
<button
key={thread.thread_id}
onClick={() => navigate(`${chatsBase}/${thread.thread_id}`)}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
thread.thread_id === currentThreadId && "bg-accent font-medium",
)}
title={desensitize(titleOfThread(thread, "新对话"))}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{desensitize(titleOfThread(thread, "新对话"))}</span>
</button>
))
)}
</div>
</div>
);
}
/**
* 右下角常驻红色操作浮层:编辑 / 下一步 / 清除任务记录。
* absolute 定位在工作区右侧面板内,z-40,时刻可见、不被聊天内容挤走。
*/
function TaskFloatingActions({ taskId }: { taskId: string }) {
const [clearOpen, setClearOpen] = useState(false); const [clearOpen, setClearOpen] = useState(false);
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
@@ -74,33 +122,28 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
}; };
return ( return (
<div className="border-border/70 bg-card/40 flex w-60 shrink-0 flex-col border-r"> <>
<div className="border-border/70 flex flex-col gap-1.5 border-b p-2"> <div className="pointer-events-none absolute bottom-4 right-4 z-40 flex flex-col items-end gap-2">
<Button size="sm" onClick={() => navigate(`${chatsBase}/new`)}>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
>
<ExternalLink className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
>
<ArrowRight className="h-3.5 w-3.5" /> 下一步
</Button>
</div>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="destructive"
className="text-destructive hover:text-destructive justify-start" className="pointer-events-auto shadow-lg"
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
>
<ExternalLink className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
>
<ArrowRight className="h-3.5 w-3.5" /> 下一步
</Button>
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => setClearOpen(true)} onClick={() => setClearOpen(true)}
disabled={!taskId} disabled={!taskId}
> >
@@ -108,29 +151,6 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
</Button> </Button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{isLoading ? (
<div className="text-muted-foreground p-2 text-sm">加载中…</div>
) : threads.length === 0 ? (
<div className="text-muted-foreground p-2 text-sm">暂无对话</div>
) : (
threads.map((thread) => (
<button
key={thread.thread_id}
onClick={() => navigate(`${chatsBase}/${thread.thread_id}`)}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
thread.thread_id === thread_id && "bg-accent font-medium",
)}
title={desensitize(titleOfThread(thread, "新对话"))}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{desensitize(titleOfThread(thread, "新对话"))}</span>
</button>
))
)}
</div>
<Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}> <Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -143,17 +163,17 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
<Button variant="outline" onClick={() => setClearOpen(false)} disabled={clearing}> <Button variant="outline" onClick={() => setClearOpen(false)} disabled={clearing}>
取消 取消
</Button> </Button>
<Button onClick={handleClear} disabled={clearing}> <Button variant="destructive" onClick={handleClear} disabled={clearing}>
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"} {clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </>
); );
} }
/** 任务工作区布局:左列表 + 右 Outlet。 */ /** 任务工作区布局:左列表 + 右 Outlet + 右下角常驻红色操作浮层。 */
export function TaskFxWorkspace({ taskBase }: { taskBase: string }) { export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
const { taskId, chatsBase } = useChatsBase(taskBase); const { taskId, chatsBase } = useChatsBase(taskBase);
// 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。 // 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
@@ -163,8 +183,9 @@ export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
return ( return (
<div className="flex size-full min-h-0 overflow-hidden"> <div className="flex size-full min-h-0 overflow-hidden">
<TaskConversationsList taskId={taskId} chatsBase={chatsBase} /> <TaskConversationsList taskId={taskId} chatsBase={chatsBase} />
<div className="min-h-0 min-w-0 flex-1"> <div className="relative min-h-0 min-w-0 flex-1">
<Outlet /> <Outlet />
<TaskFloatingActions taskId={taskId} />
</div> </div>
</div> </div>
); );
@@ -192,7 +213,7 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
); );
} }
/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板)。 */ /** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */
export function TaskFxChat({ taskBase }: { taskBase: string }) { export function TaskFxChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>(); const { agent_id } = useParams<{ agent_id: string }>();
return ( return (
@@ -1,15 +1,17 @@
/** /**
* 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。 * 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。
* *
* 流程:拉目的树并展示 → 点「开始问答」: * 展示:目的树拍平成表格(一行一条「目的→行为体→预期→因素→叙事」路径),列数随数据自适应,
* 字段/层级变动都不崩(见 flattenPurposeTree / normalizeNode 的容错)。可点「弹框查看」放大。
* 流程:点「开始问答」:
* - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart(); * - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart();
* success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。 * success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。
* - 无数据 → 直接 onStart()。 * - 无数据 → 直接 onStart()。
* onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。 * onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
*/ */
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2, Maximize2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -24,28 +26,67 @@ import {
import { import {
deleteTaskPurpose, deleteTaskPurpose,
fetchTaskPurposeDetail, fetchTaskPurposeDetail,
flattenPurposeTree,
type PurposeTreeNode, type PurposeTreeNode,
type TaskPurposeResult, type TaskPurposeResult,
} from "@/core/auth/task-purpose"; } from "@/core/auth/task-purpose";
import { cn } from "@/lib/utils";
/** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */
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);
return Array.from({ length: maxDepth }, (_, i) => {
const r = rows.find((row) => row[i]);
return r?.[i]?.label ?? `层级${i + 1}`;
});
}, [rows]);
if (rows.length === 0) {
return <div className="text-muted-foreground py-1 text-sm">暂无目的数据,可直接开始问答。</div>;
}
function PurposeNodes({ nodes, depth = 0 }: { nodes: PurposeTreeNode[]; depth?: number }) {
return ( return (
<ul className={depth === 0 ? "space-y-1" : "mt-1 space-y-1 border-l border-border/60 pl-3"}> <div className={cn("overflow-auto", className)}>
{nodes.map((node, i) => ( <table className="w-full border-collapse text-sm">
<li key={`${depth}-${i}-${node.text}`} className="text-sm leading-relaxed"> <thead>
<span className="text-muted-foreground mr-1 rounded bg-muted px-1 py-0.5 text-xs"> <tr>
{node.label} {headers.map((h, i) => (
</span> <th
<span className="font-medium">{node.text || "(空)"}</span> key={i}
{node.reason ? ( className="bg-muted/60 border-border/60 sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-nowrap"
<span className="text-muted-foreground ml-2 text-xs">理由:{node.reason}</span> >
) : null} {h}
{node.children.length > 0 ? ( </th>
<PurposeNodes nodes={node.children} depth={depth + 1} /> ))}
) : null} </tr>
</li> </thead>
))} <tbody>
</ul> {rows.map((row, ri) => (
<tr key={ri} className="even:bg-muted/30 align-top">
{headers.map((_, ci) => {
const cell = row[ci];
return (
<td key={ci} className="border-border/60 border px-3 py-1.5">
{cell ? (
<>
<span>{cell.text || "(空)"}</span>
{cell.reason ? (
<span className="text-muted-foreground block text-xs">理由:{cell.reason}</span>
) : null}
</>
) : (
<span className="text-muted-foreground/60">—</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
); );
} }
@@ -61,6 +102,7 @@ export function TaskPurposePanel({
const [result, setResult] = useState<TaskPurposeResult | null>(null); const [result, setResult] = useState<TaskPurposeResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [working, setWorking] = useState(false); const [working, setWorking] = useState(false);
const [zoomOpen, setZoomOpen] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -81,11 +123,8 @@ export function TaskPurposePanel({
}, [taskId]); }, [taskId]);
const handleStart = () => { const handleStart = () => {
if (result?.hasData) { if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖
setConfirmOpen(true); // 有数据 → 先确认覆盖 else onStart(); // 无数据 → 直接开始
} else {
onStart(); // 无数据 → 直接开始
}
}; };
const handleConfirmOverwrite = async () => { const handleConfirmOverwrite = async () => {
@@ -107,27 +146,43 @@ export function TaskPurposePanel({
} }
}; };
const hasTree = !!result && result.tree.length > 0;
return ( return (
<div className="border-border/70 bg-card/60 shrink-0 border-b px-4 py-3"> <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"> <div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">任务目的(任务 {taskId})</span> <span className="text-sm font-medium">任务目的(任务 {taskId})</span>
<Button size="sm" onClick={handleStart} disabled={loading || working}> <div className="flex items-center gap-2">
{result?.hasData ? "覆盖并开始问答" : "开始问答"} {hasTree && (
</Button> <Button size="sm" variant="outline" onClick={() => setZoomOpen(true)}>
<Maximize2 className="h-3.5 w-3.5" /> 弹框查看
</Button>
)}
<Button size="sm" onClick={handleStart} disabled={loading || working}>
{result?.hasData ? "覆盖并开始问答" : "开始问答"}
</Button>
</div>
</div> </div>
{loading ? ( {loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm"> <div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的… <Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div> </div>
) : result && result.tree.length > 0 ? (
<div className="max-h-48 overflow-y-auto pr-1">
<PurposeNodes nodes={result.tree} />
</div>
) : ( ) : (
<div className="text-muted-foreground py-1 text-sm">暂无目的数据,可直接开始问答。</div> <PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
)} )}
{/* 弹框查看:放大显示完整表格 */}
<Dialog open={zoomOpen} onOpenChange={setZoomOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>任务目的(任务 {taskId})</DialogTitle>
</DialogHeader>
<PurposeTable tree={result?.tree ?? []} className="max-h-[70vh] rounded border border-border/60" />
</DialogContent>
</Dialog>
{/* 覆盖确认 */}
<Dialog open={confirmOpen} onOpenChange={(o) => !working && setConfirmOpen(o)}> <Dialog open={confirmOpen} onOpenChange={(o) => !working && setConfirmOpen(o)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>