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:
1 parent
ba0ec20532
commit
a2bb8c80a2
5 files changed
+273
-106
No files matched your search
@@ -48,28 +48,70 @@ const DTO_FIELD_MAP: Record<string, { label: string; textKey: string }> = {
|
||||
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 {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
// 找出本节点携带的 dto 字段(目的/行为体/...)。
|
||||
const dtoKey = Object.keys(DTO_FIELD_MAP).find(
|
||||
(k) => obj[k] && typeof obj[k] === "object",
|
||||
);
|
||||
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 isObj = (k: string) => obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k]);
|
||||
// 先找已知 dto,再兜底任意 `*Dto` 字段。
|
||||
let dtoKey = Object.keys(DTO_FIELD_MAP).find(isObj);
|
||||
if (!dtoKey) dtoKey = Object.keys(obj).find((k) => /Dto$/.test(k) && isObj(k));
|
||||
|
||||
const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : [];
|
||||
const children = childrenRaw
|
||||
.map(normalizeNode)
|
||||
.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 };
|
||||
}
|
||||
|
||||
/** 表格行:根→叶一条路径上的各层单元格(层级数可变,做了容错)。 */
|
||||
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 数组并归一化成树。 */
|
||||
function bodyToTree(body: unknown): PurposeTreeNode[] {
|
||||
const data =
|
||||
|
||||
@@ -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 {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
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 { env } from "@/env";
|
||||
import { readTaskContext } from "@/core/auth/task-purpose";
|
||||
@@ -161,6 +161,22 @@ export default function AgentChatPage({
|
||||
[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(() => {
|
||||
|
||||
+81
-60
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
|
||||
*
|
||||
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+ 任务级操作
|
||||
* (新建对话 / 编辑 / 下一步 / 清除任务记录);
|
||||
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」;
|
||||
* 右下角 = 常驻浮层红色按钮(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走;
|
||||
* 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
|
||||
*
|
||||
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
|
||||
* - 新建对话 = 直接跳入:navigate 到 chats/new,AgentChatPage 闸门态展示目的树面板。
|
||||
* - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
|
||||
* - 清除任务记录:仅调 delete 接口(聊天对话保留)。
|
||||
* - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
|
||||
* 见 [[task-purpose]] / [[task-deeplink-config]]。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
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 { toast } from "sonner";
|
||||
|
||||
@@ -46,11 +46,59 @@ function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } {
|
||||
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 }) {
|
||||
const { thread_id } = useParams<{ thread_id: string }>();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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 [clearing, setClearing] = useState(false);
|
||||
|
||||
@@ -74,33 +122,28 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
<>
|
||||
<div className="pointer-events-none absolute bottom-4 right-4 z-40 flex flex-col items-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive justify-start"
|
||||
variant="destructive"
|
||||
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)}
|
||||
disabled={!taskId}
|
||||
>
|
||||
@@ -108,29 +151,6 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
|
||||
</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 === 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)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -143,17 +163,17 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas
|
||||
<Button variant="outline" onClick={() => setClearOpen(false)} disabled={clearing}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleClear} disabled={clearing}>
|
||||
<Button variant="destructive" onClick={handleClear} disabled={clearing}>
|
||||
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 任务工作区布局:左列表 + 右 Outlet。 */
|
||||
/** 任务工作区布局:左列表 + 右 Outlet + 右下角常驻红色操作浮层。 */
|
||||
export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
|
||||
const { taskId, chatsBase } = useChatsBase(taskBase);
|
||||
// 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
|
||||
@@ -163,8 +183,9 @@ export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
|
||||
return (
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
<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 />
|
||||
<TaskFloatingActions taskId={taskId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -192,7 +213,7 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板)。 */
|
||||
/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */
|
||||
export function TaskFxChat({ taskBase }: { taskBase: string }) {
|
||||
const { agent_id } = useParams<{ agent_id: string }>();
|
||||
return (
|
||||
|
||||
+88
-33
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。
|
||||
*
|
||||
* 流程:拉目的树并展示 → 点「开始问答」:
|
||||
* 展示:目的树拍平成表格(一行一条「目的→行为体→预期→因素→叙事」路径),列数随数据自适应,
|
||||
* 字段/层级变动都不崩(见 flattenPurposeTree / normalizeNode 的容错)。可点「弹框查看」放大。
|
||||
* 流程:点「开始问答」:
|
||||
* - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart();
|
||||
* success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。
|
||||
* - 无数据 → 直接 onStart()。
|
||||
* onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Maximize2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,28 +26,67 @@ import {
|
||||
import {
|
||||
deleteTaskPurpose,
|
||||
fetchTaskPurposeDetail,
|
||||
flattenPurposeTree,
|
||||
type PurposeTreeNode,
|
||||
type TaskPurposeResult,
|
||||
} 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 (
|
||||
<ul className={depth === 0 ? "space-y-1" : "mt-1 space-y-1 border-l border-border/60 pl-3"}>
|
||||
{nodes.map((node, i) => (
|
||||
<li key={`${depth}-${i}-${node.text}`} className="text-sm leading-relaxed">
|
||||
<span className="text-muted-foreground mr-1 rounded bg-muted px-1 py-0.5 text-xs">
|
||||
{node.label}
|
||||
</span>
|
||||
<span className="font-medium">{node.text || "(空)"}</span>
|
||||
{node.reason ? (
|
||||
<span className="text-muted-foreground ml-2 text-xs">理由:{node.reason}</span>
|
||||
) : null}
|
||||
{node.children.length > 0 ? (
|
||||
<PurposeNodes nodes={node.children} depth={depth + 1} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={cn("overflow-auto", className)}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="bg-muted/60 border-border/60 sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-nowrap"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [zoomOpen, setZoomOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -81,11 +123,8 @@ export function TaskPurposePanel({
|
||||
}, [taskId]);
|
||||
|
||||
const handleStart = () => {
|
||||
if (result?.hasData) {
|
||||
setConfirmOpen(true); // 有数据 → 先确认覆盖
|
||||
} else {
|
||||
onStart(); // 无数据 → 直接开始
|
||||
}
|
||||
if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖
|
||||
else onStart(); // 无数据 → 直接开始
|
||||
};
|
||||
|
||||
const handleConfirmOverwrite = async () => {
|
||||
@@ -107,27 +146,43 @@ export function TaskPurposePanel({
|
||||
}
|
||||
};
|
||||
|
||||
const hasTree = !!result && result.tree.length > 0;
|
||||
|
||||
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 size="sm" onClick={handleStart} disabled={loading || working}>
|
||||
{result?.hasData ? "覆盖并开始问答" : "开始问答"}
|
||||
</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" 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>
|
||||
) : 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)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
Reference in new issue
Block a user