From a1fbfaf8643e7c5e9ed5e79cef2d122a980e52da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 23 Jun 2026 19:14:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(xdfx):=20=E8=A1=8C=E5=8A=A8=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=9F=A5=E8=AF=A2=E4=B8=8E=E5=88=A0=E9=99=A4=EF=BC=88?= =?UTF-8?q?=E4=B8=8E=20rwfx=20=E5=85=B1=E7=94=A8=20dropSixOrSevenData=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=8E=A5=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除接口统一为 dropSixOrSevenData:rwfx 传 ?taskId=、xdfx 传 ?actionId=,另一参数直接不出现在 URL - xdfx 工作区新增「清除记录」按钮(按行动id 删除),删除前预检「行动历史是否存在」 - action_history_url 配置位(地址待提供,留空则不查询/不阻断删除) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- .../src/core/auth/task-deeplink-config.ts | 10 +- frontend-web/src/core/auth/task-purpose.ts | 55 ++++++++- .../task-components/TaskFxWorkspace.tsx | 106 +++++++++++++++++- offline-backend-20260512/backend/config.yaml | 5 +- .../deerflow/config/task_deeplink_config.py | 11 +- offline-backend-20260512/config.yaml | 5 +- 7 files changed, 182 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d727829..deaf52df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) -- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only), **but gated by the 3q precheck**: on entering the rwfx workspace `RwfxSqGateProvider` (`task-components/rwfx-sq-gate.tsx`, wraps the workspace, rwfx only) calls `fetchSqReportStatus(taskId)` — `incomplete`(3q 无数据) pops the「3q 未完成,是否前往 3qfx」dialog AND **holds the auto-send**; the question is only sent when the user picks「暂不」(or dismisses — i.e. does NOT go to 3q analysis), while「前往 3qfx」navigates and discards the send. `complete`/`unknown` send immediately (no dialog). The panel's auto-start calls `useRwfxSqGate().gateStart(onStart)` instead of `onStart()`; the manual「开始问答」/覆盖确认 path is unchanged (ungated). This replaced the old standalone `RwfxSqReportGate` (which popped the same dialog but did not gate the auto-send). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. +- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`, **plus its own「清除记录」button** (`ActionClearButton`, under 新建对话) that deletes **by 行动id**: opening its confirm dialog first runs a「行动历史是否存在」pre-check (`fetchActionHistoryExists`; query endpoint `task_deeplink.action_history_url` is **address-TBD** — empty ⇒ unknown, does **not** block the delete; once a definite「无历史」comes back it disables 确认清除), then deletes via the **shared** delete endpoint `task_deeplink.delete_url` (migrated to `dropSixOrSevenData`, used by **both** rwfx + xdfx — Six=6BF/rwfx, Seven=7BF/xdfx): `deleteTaskPurpose(id, {idKind})` sends rwfx `?taskId=` (omits actionId) / xdfx `?actionId=` (omits taskId) — the unused param is **absent from the URL**, not null. `deleteTaskPurpose` + `fetchActionHistoryExists` live in `core/auth/task-purpose.ts`. **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only), **but gated by the 3q precheck**: on entering the rwfx workspace `RwfxSqGateProvider` (`task-components/rwfx-sq-gate.tsx`, wraps the workspace, rwfx only) calls `fetchSqReportStatus(taskId)` — `incomplete`(3q 无数据) pops the「3q 未完成,是否前往 3qfx」dialog AND **holds the auto-send**; the question is only sent when the user picks「暂不」(or dismisses — i.e. does NOT go to 3q analysis), while「前往 3qfx」navigates and discards the send. `complete`/`unknown` send immediately (no dialog). The panel's auto-start calls `useRwfxSqGate().gateStart(onStart)` instead of `onStart()`; the manual「开始问答」/覆盖确认 path is unchanged (ungated). This replaced the old standalone `RwfxSqReportGate` (which popped the same dialog but did not gate the auto-send). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. - **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。 - **操作日志(Y-Log)上报** — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,**前端直连**外部接口 `POST {write_url}`(默认 `https://ch1.b.uat.4.cn/api/y-log/write`)记一条审计日志。核心模块 `src/core/log/`:`y-log.ts`(`writeYLog(params)` + `OptType` 枚举 0/1/3..12;**异步即发即忘、永不 await、永不抛错、失败静默**,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动 `JSON.stringify` + 50k 裁剪、`optResult` 恒 0 只报成功),`y-log-config.ts`(从公开只读 `GET /api/public/config/y-log` 拉 `{enabled, writeUrl}`,模块级缓存 + 去重 + 默认兜底;`App.tsx` `AppProviders` 启动预热 `fetchYLogConfig()`),`y-log-token.ts`(token1 = token 登录的 `?authToken=`,经 [[scoped-storage]] 的 `authStorage*` 存取,嵌入态自动隔离)。**双门控**:仅当①存在 token1(即用 token 登录,`LoginPage` 成功后 `persistYLogToken(authToken)`,登出 `account-settings-page.tsx` 先报后清 token1)且②配置 `enabled && writeUrl` 才上报;否则一律不发。`optDetail` 统一「B1-CM助手-…」(**禁内部框架名**)。埋点集中在各 API client 的成功路径:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs}.ts`、`core/{agents,scheduled-tasks,knowledge}/api.ts`、`core/api/markdown-docx-export.ts`,以及问答完成钩子(`ChatPage`/`AgentChatPage` 的 `useThreadStream` `onFinish`,用最终汇总文本作 `returnParam`,optType 12)。后端仅新增公开只读配置:`deerflow/config/y_log_config.py`(`YLogConfig{enabled,write_url}`)挂到 `AppConfig.y_log`,`app/gateway/routers/public_config.py` 加 `GET /api/public/config/y-log`,地址写在 `config.yaml` 的 `y_log` 段(mtime 热重载,前端无需重建);**不做后端代理**。Tests:`tests/test_y_log_config.py`。 diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts index be9b18ac..c0e13ad8 100644 --- a/frontend-web/src/core/auth/task-deeplink-config.ts +++ b/frontend-web/src/core/auth/task-deeplink-config.ts @@ -23,8 +23,10 @@ export interface TaskDeeplinkConfig { actionTest: boolean; /** 目的树查询完整 URL(调用追加 ?taskId=)。 */ purposeDetailUrl: string; - /** 删除完整 URL(调用追加 ?taskId=)。 */ + /** 删除完整 URL(rwfx 与 xdfx 共用同一接口):rwfx 追加 ?taskId=、xdfx 追加 ?actionId=(另一参数不传)。 */ deleteUrl: string; + /** xdfx「行动历史是否存在」查询完整 URL(调用追加 ?actionId=);留空 → 前端视作无历史、不阻断删除(地址待提供)。 */ + actionHistoryUrl: string; /** B 系统树形完整编辑页 URL(跳转追加 ?id=)。 */ treemapEditUrl: string; /** B 系统树形下一步页 URL(跳转追加 ?id=)。 */ @@ -50,10 +52,12 @@ export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = { deleteTest: false, actionTest: false, purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail", - deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete", + deleteUrl: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData", treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit", treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next", actionDetailUrl: "https://ch1.b.uat.4.cn/consumer/plan/action", + // 行动历史查询地址待提供:空字符串 → 前端不查询、视作无历史(不阻断删除)。 + actionHistoryUrl: "", detail3qUrl: "", mock: {}, }; @@ -74,6 +78,8 @@ function normalize(body: unknown): TaskDeeplinkConfig { treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl), treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl), actionDetailUrl: str(o.action_detail_url, d.actionDetailUrl), + // action_history_url 允许为空(地址待提供):空 → 前端不查询、视作无历史。不回退默认。 + actionHistoryUrl: typeof o.action_history_url === "string" ? o.action_history_url : d.actionHistoryUrl, // detail_3q_url 允许为空(地址待定),不回退默认;只在配了非空时才生效。 detail3qUrl: typeof o.detail_3q_url === "string" ? o.detail_3q_url : d.detail3qUrl, mock: { diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts index 375e3c51..2db6b42e 100644 --- a/frontend-web/src/core/auth/task-purpose.ts +++ b/frontend-web/src/core/auth/task-purpose.ts @@ -254,20 +254,35 @@ export interface DeleteTaskPurposeResult { } /** - * 删除任务目的树(覆盖前清空 / 手动清除任务记录都走这里)。``delete_test=true`` 时 - * 用后端配置里的假数据(接口未开发完时用);否则打真实接口。 + * 删除记录的「按哪种 id 删」: + * - ``task``(默认,rwfx):传 ``?taskId=``,不传 actionId; + * - ``action``(xdfx 行动分析):传 ``?actionId=``,不传 taskId。 + * rwfx 与 xdfx 共用同一个删除接口(``delete_url`` = dropSixOrSevenData); + * 「不传」= 该参数**根本不出现在 URL 里**(只把要传的那个 key 放进 params,axios 自然省略另一个), + * 不是传空值/None。 + */ +export type DeleteIdKind = "task" | "action"; + +/** + * 删除任务/行动记录(rwfx 覆盖前清空 / 手动清除 / xdfx 清除行动记录都走这里)。 + * ``delete_test=true`` 时用后端配置里的假数据(接口未开发完时用);否则打真实接口 + * (rwfx 传 ``taskId``、xdfx 传 ``actionId``,见 ``DeleteIdKind``)。 */ export async function deleteTaskPurpose( - taskId: string, + id: string, + opts?: { idKind?: DeleteIdKind }, ): Promise { - if (!taskId) return { message: "缺少 taskId", success: false }; + const idKind = opts?.idKind ?? "task"; + if (!id) return { message: idKind === "action" ? "缺少 actionId" : "缺少 taskId", success: false }; const cfg = await fetchTaskDeeplinkConfig(); if (cfg.deleteTest) { const m = cfg.mock.delete ?? {}; return { message: typeof m.message === "string" ? m.message : "", success: m.success === true }; } if (!cfg.deleteUrl) return { message: "缺少删除接口地址", success: false }; - const body = await requestCrudService.get(cfg.deleteUrl, { taskId }); + // 只放要传的那个 id key:另一参数直接不出现在 URL(axios 不会序列化缺省 key)。 + const params = idKind === "action" ? { actionId: id } : { taskId: id }; + const body = await requestCrudService.get(cfg.deleteUrl, params); const obj = body && typeof body === "object" ? (body as unknown as Record) : {}; return { @@ -276,6 +291,36 @@ export async function deleteTaskPurpose( }; } +export interface ActionHistoryResult { + /** 是否存在可删除的「行动历史」记录。 */ + exists: boolean; + /** 是否成功判定。接口未配置 / 请求失败 → false(调用方据此**不**做「无历史」拦截,仍允许删除)。 */ + known: boolean; +} + +/** + * 查询 xdfx「行动历史是否存在」(清除行动记录前的预检),按「行动id」调用。 + * + * 查询接口(``action_history_url``)地址**目前待提供**:为空时直接返回 + * ``{ exists: false, known: false }``——视作空白、不查询,也不阻断删除;后端配上真实地址后自动生效。 + * + * ⚠️ 真实返回结构待定:拿到接口后按实际字段调整「是否存在」的判定(当前暂按 ``data`` 非空兜底)。 + */ +export async function fetchActionHistoryExists(actionId: string): Promise { + if (!actionId) return { exists: false, known: false }; + const cfg = await fetchTaskDeeplinkConfig(); + if (!cfg.actionHistoryUrl) return { exists: false, known: false }; + try { + const body = await requestCrudService.get(cfg.actionHistoryUrl, { actionId }); + // TODO(接口就绪):按真实返回结构判定。暂按整包 body.data 非空作「存在历史」。 + const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; + const exists = Array.isArray(data) ? data.length > 0 : data != null && data !== ""; + return { exists, known: true }; + } catch { + return { exists: false, known: false }; + } +} + /* ----------------------------- 跳转地址 ----------------------------- */ /** B 系统「树形完整编辑」页地址(问答后「编辑」按钮,新标签打开)。用同步缓存配置。 */ diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index 93effc72..bc2cd190 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -29,7 +29,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose"; +import { + deleteTaskPurpose, + fetchActionHistoryExists, + readTaskContext, + type ActionHistoryResult, +} from "@/core/auth/task-purpose"; import { fetchActionDetail, readCopTaskDetail, @@ -195,6 +200,8 @@ function TaskConversationsList({ 新建对话 {!lite && } + {/* xdfx:按「行动id」清除行动记录(删除前查历史是否存在;与 rwfx 共用同一删除接口)。 */} + {business === "xdfx" && actionId && }
@@ -292,6 +299,103 @@ function TaskClearButton({ taskId }: { taskId: string }) { ); } +/** + * 「清除记录」按钮(xdfx 行动分析):放在「新建对话」下面,按「行动id」清除该行动在 B 系统里的 + * 策划数据(聊天对话保留),与 rwfx 共用同一个删除接口(仅传 ``actionId``、不传 taskId)。 + * + * 打开弹框时先「查询历史是否存在」(``fetchActionHistoryExists``): + * - 接口未就绪(地址未配置)→ ``known=false``:当作未知,仍可删除(不拦截); + * - 查到「无历史」(``known && !exists``)→ 禁用「确认清除」(没有可删的数据)。 + */ +function ActionClearButton({ actionId }: { actionId: string }) { + const [open, setOpen] = useState(false); + const [clearing, setClearing] = useState(false); + const [checking, setChecking] = useState(false); + const [history, setHistory] = useState(null); + + // 打开弹框时查历史是否存在(接口未配置 → known=false,不影响删除)。 + useEffect(() => { + if (!open) return; + let alive = true; + setChecking(true); + setHistory(null); + void fetchActionHistoryExists(actionId) + .then((r) => { + if (alive) setHistory(r); + }) + .finally(() => { + if (alive) setChecking(false); + }); + return () => { + alive = false; + }; + }, [open, actionId]); + + const handleClear = async () => { + setClearing(true); + try { + const r = await deleteTaskPurpose(actionId, { idKind: "action" }); + if (r.success) toast.success("已清除行动记录"); + else toast.error(r.message || "清除失败"); + } catch { + toast.error("清除失败,请稍后再试"); + } finally { + setClearing(false); + setOpen(false); + } + }; + + // 仅当「明确查到无历史」时禁用删除;接口未就绪(known=false)仍允许删除。 + const noHistory = history?.known === true && history.exists === false; + + return ( + <> + + + !clearing && setOpen(o)}> + + + 清除行动记录 + + 将清除该行动在 B 系统中的策划数据(聊天对话保留),是否确认? + + + {checking ? ( +
+ 正在查询历史… +
+ ) : history?.known === true ? ( +
+ {history.exists ? "检测到该行动的历史记录。" : "未检测到该行动的历史记录,无需清除。"} +
+ ) : null} + + + + +
+
+ + ); +} + /** * 任务工作区布局:左列表 + 右 Outlet。 * ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。 diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 6b8593f0..ffd9bf95 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -431,11 +431,14 @@ task_deeplink: delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" - delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete" + # rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。 + delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData" treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit" treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next" # xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。 action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action" + # xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。 + action_history_url: "" # rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。 detail_3q_url: "" mock: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py index 535e8faf..f86d7374 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py @@ -8,8 +8,13 @@ - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据; 同时 rwfx 进入「3q 是否完成」预检(``selectSqReport``,外部接口不可用)走 ``mock.sq_report`` 假数据。 - ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。 + rwfx 与 xdfx 共用同一个删除接口 ``dropSixOrSevenData``:rwfx 传 ``?taskId=``、xdfx 传 ``?actionId=`` + (另一参数直接不传,不出现在 URL 里)。 - ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。 xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。 + +``action_history_url`` 为 xdfx 删除前「行动历史是否存在」预检接口,地址待提供:留空则前端视作 +「无历史/不查询」,不阻断删除(后续配上真实地址即生效)。 """ from __future__ import annotations @@ -44,13 +49,17 @@ class TaskDeeplinkConfig(BaseModel): default="", description="行动详情接口基址(xdfx 行动→任务),调用时追加 /{actionId} 路径段;返回 data.taskId 为真正任务id", ) + action_history_url: str = Field( + default="", + description="xdfx「行动历史是否存在」查询完整 URL(删除前预检),调用时追加 ?actionId=;地址待提供,留空则前端视作「无历史/不查询」(不阻断删除)", + ) detail_3q_url: str = Field( default="", description="「3q详情」详情页 URL(rwfx 目的树面板的链接),跳转时追加 ?id=(任务id)", ) delete_url: str = Field( default="", - description="删除完整 URL,调用时追加 ?taskId=", + description="删除完整 URL(rwfx 与 xdfx 共用同一个删除接口 dropSixOrSevenData):rwfx 调用追加 ?taskId=(不传 actionId),xdfx 调用追加 ?actionId=(不传 taskId)——另一参数直接不出现在 URL 里,不是空值", ) treemap_edit_url: str = Field( default="", diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 3cb025ee..ed9803d0 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -349,11 +349,14 @@ task_deeplink: delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" - delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete" + # rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。 + delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData" treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit" treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next" # xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。 action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action" + # xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。 + action_history_url: "" # rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。 detail_3q_url: "" mock: