diff --git a/CLAUDE.md b/CLAUDE.md index a46ed807..f3c34921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,11 +82,12 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. +- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **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 (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. 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/:thread_id}` 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. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.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). 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)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId) + 新建对话; 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). 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`. ### Configuration diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 33b79a6b..d01d6d6e 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -8,6 +8,7 @@ import { Folder, FolderOpen, ListTree, + MousePointerClick, Settings, ShieldAlert, User, @@ -213,6 +214,14 @@ function SecondaryNavSidebar() { 菜单管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/task-buttons")} + > + + 按钮管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")} @@ -493,6 +502,12 @@ function LegacyAccordionSidebar({ 菜单管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/task-buttons")}> + + 按钮管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")}> diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index f9a3292e..9a40afc3 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -131,14 +131,17 @@ export function AgentDetailPanel({ agentName, onAskQuestion, disabled, + defaultCollapsed, }: { agent: Agent | null | undefined; agentName: string; onAskQuestion: (text: string) => void; disabled?: boolean; + /** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */ + defaultCollapsed?: boolean; }) { const router = useRouter(); - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); const [mobileOpen, setMobileOpen] = useState(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(null); diff --git a/frontend-web/src/core/auth/task-buttons.ts b/frontend-web/src/core/auth/task-buttons.ts new file mode 100644 index 00000000..e7f10d42 --- /dev/null +++ b/frontend-web/src/core/auth/task-buttons.ts @@ -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 }) => void; + /** 跳转/打开失败时的提示回调(默认 noop)。 */ + onError?: (message: string) => void; +} + +/** + * 执行一个跳转按钮: + * - ``url``:拼地址(可选追加 ``?id=``,取任务id 或行动id 视 ``idKind``)新标签打开;地址为空提示未配置。 + * - ``business``:复用深链解析(会按目标业务重绑任务上下文)后 ``navigate``(始终用任务id)。 + */ +export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Promise { + 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("跳转失败,请稍后再试"); + } +} diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts index f31a74a3..86d5b716 100644 --- a/frontend-web/src/core/auth/task-deeplink-config.ts +++ b/frontend-web/src/core/auth/task-deeplink-config.ts @@ -19,6 +19,8 @@ export interface TaskDeeplinkConfig { test: boolean; /** 删除接口走假数据(该接口未开发完时打开)。 */ deleteTest: boolean; + /** 行动→任务解析走假数据(xdfx 行动id→任务id)。 */ + actionTest: boolean; /** 目的树查询完整 URL(调用追加 ?taskId=)。 */ purposeDetailUrl: string; /** 删除完整 URL(调用追加 ?taskId=)。 */ @@ -27,10 +29,16 @@ export interface TaskDeeplinkConfig { treemapEditUrl: string; /** B 系统树形下一步页 URL(跳转追加 ?id=)。 */ treemapNextUrl: string; - /** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}。 */ + /** 行动详情接口基址(xdfx 行动→任务):调用追加 /{actionId},返回 data.taskId 为真正任务id。 */ + actionDetailUrl: string; + /** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */ + detail3qUrl: string; + /** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}; + * actionDetail = 原始 {state,msg,data:{taskId,...}}。 */ mock: { purposeDetail?: unknown; delete?: { message?: string; success?: boolean }; + actionDetail?: unknown; }; } @@ -38,10 +46,13 @@ export interface TaskDeeplinkConfig { export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = { test: false, deleteTest: false, + actionTest: false, purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail", deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete", 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", + detail3qUrl: "", mock: {}, }; @@ -55,16 +66,21 @@ function normalize(body: unknown): TaskDeeplinkConfig { return { test: o.test === true, deleteTest: o.delete_test === true, + actionTest: o.action_test === true, purposeDetailUrl: str(o.purpose_detail_url, d.purposeDetailUrl), deleteUrl: str(o.delete_url, d.deleteUrl), treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl), treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl), + actionDetailUrl: str(o.action_detail_url, d.actionDetailUrl), + // detail_3q_url 允许为空(地址待定),不回退默认;只在配了非空时才生效。 + detail3qUrl: typeof o.detail_3q_url === "string" ? o.detail_3q_url : d.detail3qUrl, mock: { purposeDetail: mock.purpose_detail, delete: mock.delete && typeof mock.delete === "object" ? (mock.delete as { message?: string; success?: boolean }) : undefined, + actionDetail: mock.action_detail, }, }; } diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 84867792..08e73e69 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -17,10 +17,11 @@ import { requestCrudService } from "@/service/request-axios-service"; import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose"; +import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config"; import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; -/** rwfx 业务码:单智能体走「任务工作区 + 目的树闸门」(其它业务保持原问答逻辑)。 */ -const RWFX_BUSINESS_CODE = "6BF"; +/** xdfx 业务码:深链传入的是「行动id」,需先调 action 接口解析出真正的「任务id」。 */ +const XDFX_BUSINESS_CODE = "7BF"; /** consumer 接口返回的任务详情(data 字段)。 */ export interface CopTaskDetail { @@ -110,6 +111,44 @@ export function readCopTaskDetail(): CopTaskDetail | null { } } +/** + * xdfx 深链:用「行动id」调行动详情接口 ``{action_detail_url}/{actionId}``,取出 ``data.taskId`` + * 作为真正的「任务id」。``action_test=true`` 时直接用后端配置里的假数据(接口未通时用)。 + * 缺地址 / 返回不合法 → 返回 ""(调用方据此回退)。 + */ +export async function fetchTaskIdFromAction(actionId: string): Promise { + if (!actionId) return ""; + const cfg = await fetchTaskDeeplinkConfig(); + let body: unknown; + if (cfg.actionTest) { + body = cfg.mock.actionDetail; + } else if (cfg.actionDetailUrl) { + const base = cfg.actionDetailUrl.replace(/\/+$/, ""); + // service 拦截器已统一注入 Authorization: Bearer admin 并返回整包 body;行动id 走路径段。 + body = await requestCrudService.get(`${base}/${encodeURIComponent(actionId)}`); + } else { + return ""; + } + const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; + if (!data || typeof data !== "object") return ""; + const taskId = (data as Record).taskId; + return taskId != null && taskId !== "" ? String(taskId) : ""; +} + +/** + * 据 goPath 把深链 URL 里的原始 id 解析成真正的「任务id」: + * - xdfx(行动分析):URL 传入的是「行动id」,调 action 接口换成 ``data.taskId``; + * 解析失败返回 ""(让调用方回退默认首页,避免拿行动id当任务id污染后续查询/绑定)。 + * - 其它业务:URL 传入的本就是任务id,原样返回。 + */ +export async function resolveDeeplinkTaskId(goPath: string, rawId: string): Promise { + const parsed = parseGoPath(goPath); + if (parsed?.businessCode === XDFX_BUSINESS_CODE) { + return fetchTaskIdFromAction(rawId); + } + return rawId; +} + export interface TaskDeeplinkTarget { path: string; /** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */ @@ -135,20 +174,23 @@ 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 { const parsed = parseGoPath(goPath); if (!parsed) return null; const taskBase = BUSINESS_TASK_BASE[parsed.businessCode]; if (!taskBase) return null; - // 清掉上一次深链可能残留的任务上下文(仅 rwfx 单智能体会重新写入)。 + // 清掉上一次深链可能残留的任务上下文(单智能体分支会用本次任务重新写入)。 clearTaskContext(); if (parsed.roundtable) { @@ -177,22 +219,15 @@ export async function resolveTaskDeeplinkTarget( } if (!agentId) return null; - if (parsed.businessCode === RWFX_BUSINESS_CODE) { - // rwfx:走「任务工作区」——暂存任务上下文(taskId + 开场信息),跳到 chats 索引, - // 由工作区按 taskId 决定开最新历史对话 / 新建(新建走目的树闸门)。不设 pendingAgentMessage: - // 开场信息在闸门通过后由目的树面板发出。 - persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail, taskId) }); - return { path: `${taskBase}/agent/${agentId}/chats` }; - } - - // 其它单智能体问答:保持原逻辑(pendingAgentMessage 自动发出 + chats/new)。 - try { - sessionStorage.setItem( - "pendingAgentMessage", - JSON.stringify({ agentId, text: buildOpeningText(detail, taskId) }), - ); - } catch { - /* noop */ - } - return { path: `${taskBase}/agent/${agentId}/chats/new` }; + // 单智能体问答统一走「任务工作区」(任务↔对话 1:N,按 taskId + agentId 隔离不同业务的对话列表): + // 暂存任务上下文(taskId + agentId + 开场信息),跳到 chats 索引,由工作区按 taskId 决定 + // 开最新历史对话 / 新建。rwfx 在工作区里额外过「目的树闸门」;3qfx/xdfx 仅 1:N 列表, + // 新建对话直接带开场信息自动发出(见 task-purpose / AgentChatPage 的 taskGate / taskBind)。 + persistTaskContext({ + taskId, + agentId, + openingText: buildOpeningText(detail, taskId), + actionId: rawId || taskId, + }); + return { path: `${taskBase}/agent/${agentId}/chats` }; } diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts index a3f8234f..90685a0b 100644 --- a/frontend-web/src/core/auth/task-purpose.ts +++ b/frontend-web/src/core/auth/task-purpose.ts @@ -39,6 +39,8 @@ export interface TaskContext { agentId: string; /** 自动发出的开场信息(任务名称/内容)。 */ openingText: string; + /** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */ + actionId?: string; } const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext"; @@ -273,6 +275,12 @@ export function buildTreemapNextUrl(taskId: string): string { return url && taskId ? appendQueryParam(url, "id", taskId) : ""; } +/** 「3q详情」详情页地址(目的树面板链接,新标签打开);追加 ?id=任务id。地址未配置时返回 ""。 */ +export function buildDetail3qUrl(taskId: string): string { + const url = getCachedTaskDeeplinkConfig().detail3qUrl; + return url && taskId ? appendQueryParam(url, "id", taskId) : ""; +} + /* --------------------------- 任务上下文暂存 --------------------------- */ export function persistTaskContext(ctx: TaskContext): void { @@ -293,6 +301,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; diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 6d34117b..a84e1c32 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -785,7 +785,10 @@ export function useThreadStream({ } as typeof thread) : thread; - return [mergedThread, sendMessage, isUploading] as const; + // 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。 + // 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话 + // (submit 的 threadId 选项仅在 hook 线程为 null/undefined 时才生效)。 + return [mergedThread, sendMessage, isUploading, onStreamThreadId] as const; } export function useThreads( diff --git a/frontend-web/src/core/threads/use-task-threads.ts b/frontend-web/src/core/threads/use-task-threads.ts index cad4b9f1..b9101dc4 100644 --- a/frontend-web/src/core/threads/use-task-threads.ts +++ b/frontend-web/src/core/threads/use-task-threads.ts @@ -4,19 +4,27 @@ * 新建对话时由 [[task-purpose]] 流程给 thread 打 ``metadata.taskId`` 标签 * (见 useThreadStream 的 ``threadMetadata``);这里复用服务端分页搜索按该标签过滤, * 后端默认按 ``updated_at desc`` 排序,所以 ``threads[0]`` 即最新一条。 + * + * 同一个 taskId 可能挂在多条业务链上(rwfx / 3qfx / xdfx 共用任务 id),每条业务用各自的 + * 智能体。传入 ``agentId`` 时额外按 ``metadata.agent_id`` 过滤(后端 metadata 过滤是 AND/子集 + * 匹配),把不同业务的对话列表隔离开——否则三种业务的对话会混在一起。 */ import { useThreadsPage } from "@/core/threads/hooks"; import type { AgentThread } from "@/core/threads/types"; -export function useTaskThreads(taskId: string | null | undefined) { +export function useTaskThreads( + taskId: string | null | undefined, + agentId?: string | null | undefined, +) { const enabled = !!taskId; // 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。 - const query = useThreadsPage({ - page: 1, - pageSize: 100, - metadata: enabled ? { taskId } : { taskId: "__none__" }, - }); + const metadata = enabled + ? agentId + ? { taskId, agent_id: agentId } + : { taskId } + : { taskId: "__none__" }; + const query = useThreadsPage({ page: 1, pageSize: 100, metadata }); const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : []; return { ...query, diff --git a/frontend-web/src/lib/desensitize.ts b/frontend-web/src/lib/desensitize.ts index 38546f01..5440451d 100644 --- a/frontend-web/src/lib/desensitize.ts +++ b/frontend-web/src/lib/desensitize.ts @@ -11,6 +11,10 @@ * {@link DESENSITIZE_MAP}。 */ export const SEED_DESENSITIZE_MAP: Readonly> = Object.freeze({ + // 任务工作区业务名 → 业务码(脱敏展示)。整词在前,优先于下面更短的「任务」「行动」匹配。 + 任务分析: "rwfx", + 三情分析: "3qfx", + 行动分析: "xdfx", 方案: "FA", 设计: "SJ", 任务: "RW", diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 2d7372cd..19008977 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -37,6 +37,8 @@ export default function AgentChatPage({ embeddedAgentId, chatBasePath, taskGate, + taskBind, + autoStartWhenPurposeEmpty, }: { /** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。 * 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */ @@ -49,6 +51,11 @@ export default function AgentChatPage({ /** rwfx 任务深链闸门:新建对话时顶部展示目的树面板(覆盖确认后才开始问答), * 并给新建 thread 打 `metadata.taskId` 标签(任务↔对话 1:N)。见 task-purpose。 */ taskGate?: boolean; + /** 3qfx / xdfx 任务深链:仅做任务↔对话 1:N 绑定(打 `metadata.taskId` 标签 + 切换会话停旧 run), + * 不展示目的树闸门;新建对话直接把任务开场信息自动发出。与 `taskGate` 互斥使用。 */ + taskBind?: boolean; + /** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */ + autoStartWhenPurposeEmpty?: boolean; } = {}) { const routeBase = useRouteBase(); const { t } = useI18n(); @@ -63,15 +70,23 @@ export default function AgentChatPage({ // 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。 const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`; - // rwfx 任务闸门:读取登录深链/「新建对话」暂存的任务上下文(taskId + 开场信息)。 + // 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。 + const taskCtxOn = !!(taskGate || taskBind); const taskContext = useMemo( - () => (taskGate ? readTaskContext() : null), - [taskGate], + () => (taskCtxOn ? readTaskContext() : null), + [taskCtxOn], ); const taskId = - taskGate && taskContext && taskContext.agentId === agent_id + taskCtxOn && taskContext && taskContext.agentId === agent_id ? taskContext.taskId : ""; + // 纯绑定(taskBind 且无闸门):新建对话自动发出的任务开场信息。rwfx 走闸门发出,不在此列。 + const taskAutoSendText = + taskBind && !taskGate && taskContext && taskContext.agentId === agent_id + ? (taskContext.openingText?.trim() ?? "") + : ""; + // 纯绑定每次进入新建对话都自动发一次开场信息;ref 防止同一新建会话内重复发出。 + const taskAutoSentRef = useRef(false); // If ChatPage stashed an opening message for this agent, skip the landing // screen on first render so the user doesn't see a flash of the welcome UI @@ -88,7 +103,7 @@ export default function AgentChatPage({ } }; const [hasStartedConversation, setHasStartedConversation] = useState( - hasPendingForThisAgent, + () => hasPendingForThisAgent() || !!taskAutoSendText, ); const { agent, isLoading: isAgentLoading } = useAgent(agent_id); @@ -107,13 +122,15 @@ export default function AgentChatPage({ useEffect(() => { if (isNewThread) { - setHasStartedConversation(hasPendingForThisAgent()); + setHasStartedConversation(hasPendingForThisAgent() || !!taskAutoSendText); + // 进入新建对话:允许纯绑定再次自动发出开场信息。 + taskAutoSentRef.current = false; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNewThread, threadId, agent_id]); const { showNotification } = useNotification(); - const [thread, sendMessage] = useThreadStream({ + const [thread, sendMessage, , streamThreadId] = useThreadStream({ threadId: isNewThread ? undefined : threadId, assistantId: agent_id, context: { ...settings.context, agent_id }, @@ -162,20 +179,20 @@ export default function AgentChatPage({ ); // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。 - // 仅 taskGate(rwfx 任务工作区)启用——普通问答页保留后台续跑行为不变。 + // 任务工作区(rwfx 闸门 + 3qfx/xdfx 纯绑定)启用——普通问答页保留后台续跑行为不变。 const prevTaskThreadRef = useRef(null); useEffect(() => { - if (!taskGate) return; + if (!taskCtxOn) return; const prev = prevTaskThreadRef.current; if (prev && prev !== threadId) void stopThreadRun(prev); prevTaskThreadRef.current = threadId ?? null; - }, [taskGate, threadId]); + }, [taskCtxOn, threadId]); useEffect(() => { // 离开任务问答页(卸载)时也停掉当前会话的服务端 run。 return () => { - if (taskGate) void stopThreadRun(prevTaskThreadRef.current); + if (taskCtxOn) void stopThreadRun(prevTaskThreadRef.current); }; - }, [taskGate]); + }, [taskCtxOn]); // 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则 // 仅切到对话态(隐藏面板),让用户手动输入。 @@ -210,6 +227,22 @@ export default function AgentChatPage({ handleSubmit({ text: pending.text, files: [] }); }, [agent_id, threadId, isNewThread, handleSubmit]); + // 纯绑定任务深链(3qfx/xdfx,无闸门):进入新建对话即自动发出任务开场信息。 + // 每次新建对话都带(与 rwfx 闸门「开始问答」发出等价);ref 防止同一会话内重复发出。 + // + // 必须等 `streamThreadId == null`(底层 stream 已切到全新线程)才发:从已有会话点「新建对话」时, + // `isNewThread` 先翻 true,但 useThreadStream 的 onStreamThreadId 要慢一帧才重置为 null。 + // 若在那一帧就 submit,hook 仍绑定上一个会话,submit 的 threadId 选项被忽略 → 开场信息发进 + // 上一个对话(表现为「一直在同一个聊天框里发消息」)。等它归零再发即落到新建线程。 + useEffect(() => { + if (!taskAutoSendText) return; + if (!isNewThread) return; + if (streamThreadId != null) return; + if (taskAutoSentRef.current) return; + taskAutoSentRef.current = true; + handleSubmit({ text: taskAutoSendText, files: [] }); + }, [taskAutoSendText, isNewThread, streamThreadId, handleSubmit]); + const handleClarificationSubmit = useCallback( (answer: string) => { setHasStartedConversation(true); @@ -223,8 +256,9 @@ export default function AgentChatPage({ }, [thread]); const isLandingState = isNewThread && !hasStartedConversation; - // 任务闸门面板:仅在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。 - const showTaskGate = !!taskId && isNewThread && !hasStartedConversation; + // 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。 + // 3qfx/xdfx(taskBind)不展示闸门,靠 taskAutoSendText 直接发出开场信息。 + const showTaskGate = !!taskGate && !!taskId && isNewThread && !hasStartedConversation; const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; const status = thread.error @@ -290,7 +324,11 @@ export default function AgentChatPage({ {showTaskGate && ( - + )} {isLandingState ? ( @@ -403,6 +441,7 @@ export default function AgentChatPage({ agentName={agentDisplayName} disabled={thread.isLoading || isDemoMode} onAskQuestion={(text) => handleSubmit({ text, files: [] })} + defaultCollapsed={taskCtxOn} /> diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index a8b91c68..45009d45 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -15,6 +15,7 @@ import { import { fetchCopTaskDetail, persistCopTaskDetail, + resolveDeeplinkTaskId, resolveTaskDeeplinkTarget, } from '@/core/auth/task-deeplink'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; @@ -160,11 +161,14 @@ export default function LoginPage() { // 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。 if (taskId && goPath) { try { - const detail = await fetchCopTaskDetail(taskId); + // xdfx 深链 ?taskId= 传入的是「行动id」,先换成真正的「任务id」;其它业务原样。 + const realTaskId = await resolveDeeplinkTaskId(goPath, taskId); + if (cancelled) return; + const detail = await fetchCopTaskDetail(realTaskId); if (cancelled) return; if (detail) { persistCopTaskDetail(detail); - const target = await resolveTaskDeeplinkTarget(goPath, detail, taskId); + const target = await resolveTaskDeeplinkTarget(goPath, detail, realTaskId, taskId); if (cancelled) return; if (target) { navigate(target.path, { replace: true, state: target.state }); diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 306cc25a..b3db1236 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -1,4 +1,4 @@ -import { Navigate, Route, Routes, useParams } from "react-router-dom"; +import { Navigate, Route, Routes } from "react-router-dom"; import { Toaster } from "sonner"; import { SidebarProvider } from "@/components/ui/sidebar"; @@ -12,6 +12,7 @@ import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyF import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom"; import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes"; import { + TaskBindChat, TaskFxChat, TaskFxIndexResolver, TaskFxWorkspace, @@ -39,6 +40,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"; @@ -59,20 +61,6 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminUserActivityPage from "./AdminUserActivityPage"; import { ChatRuntime } from "./WorkspaceRoutes"; -/** - * 任务深链:把智能体问答嵌进「任务管理」布局。读路由参数 agent_id,给 AgentChatPage - * 传入以 taskBase 为前缀的对话路由(开新会话 / URL 改写都停留在任务管理上下文, - * 侧边栏保持对应业务高亮)。 - */ -function TaskEmbeddedAgentChat({ taskBase }: { taskBase: string }) { - const { agent_id } = useParams<{ agent_id: string }>(); - return ( - - - - ); -} - export default function StrategyRoutes() { return ( @@ -155,12 +143,26 @@ export default function StrategyRoutes() { } /> + {/* 三情分析:任务工作区(左对话列表 1:N,无目的树闸门;新建对话直接带开场信息)。 */} + } - /> + > + + } + /> + + } + /> + + } > + } /> @@ -219,12 +224,27 @@ export default function StrategyRoutes() { } /> + {/* 行动分析:任务工作区(左对话列表 1:N 精简版;右侧带目的树闸门,与 rwfx 同一查询/显示)。 + 注意:xdfx 深链传入的是「行动id」,登录时已先经 resolveDeeplinkTaskId 换成任务id。 */} + } - /> + > + + } + /> + + } + /> + } /> } /> } /> @@ -362,6 +382,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> `${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(() => DEFAULT_TASK_DEEPLINK_CONFIG); + useEffect(() => { + let alive = true; + void fetchTaskDeeplinkConfig().then((c) => { + if (alive) setCfg(c); + }); + return () => { + alive = false; + }; + }, []); + + const [rows, setRows] = useState([]); + const [loadedKey, setLoadedKey] = useState(null); + const [tab, setTab] = useState("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 ; + + const update = (id: string, patch: Partial) => + 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 ( +
+
+
+ +
+
按钮管理
+
+ 配置任务工作区底部「快捷跳转」栏的按钮(名称 / 跳转方式 / 地址 / 启停 / 排序) +
+
+
+
+ + +
+
+ + {/* 业务 tab:每个 tab = 一个任务深链页面,明确区分三种页面的按钮 */} +
+ {TASK_BUSINESSES.map((b) => { + const count = rows.filter((r) => r.business === b.code).length; + const active = tab === b.code; + return ( + + ); + })} +
+ + {/* 当前页面说明 */} + {(() => { + const meta = TASK_BUSINESSES.find((b) => b.code === tab)!; + return ( +
+ 正在编辑「{meta.name} + 」页面的按钮 · {meta.desc} 下方按钮会显示在该页面底部的「快捷跳转」栏,从上到下对应栏内从左到右的顺序。 +
+ ); + })()} + +
+ {isLoading ? ( +
加载中…
+ ) : tabRows.length === 0 ? ( +
+ 该页面暂无按钮。可「导入该页面默认按钮」一键填充,或点下方「添加按钮」自定义。 + +
+ ) : ( +
+ {tabRows.map((r, idx) => ( +
+
+
+ + update(r.id, { label: e.target.value })} + placeholder="按钮名称" + className="h-8 max-w-xs" + /> + + +
+ +
+ + {r.linkType === "purpose" ? ( + + 点击弹出当前任务的「任务目的」表格,无需配置目标。 + + ) : r.linkType === "url" ? ( + <> + update(r.id, { target: e.target.value })} + placeholder="https://…(跳转地址)" + className="h-8 flex-1 min-w-[16rem]" + /> + + {r.appendTaskId && ( +
+ ? + update(r.id, { idParam: e.target.value })} + placeholder="id" + className="h-8 w-24" + title="查询参数名(如 id / task_id)" + /> + = + {/* 行动分析(xdfx):行动id 与任务id 不同,可选追加哪种;其它业务固定任务id */} + {tab === "xdfx" ? ( + + ) : ( + 任务id + )} +
+ )} + + ) : ( + + )} +
+
+ +
+ + + + +
+
+ ))} +
+ )} + + +
+
+ ); +} diff --git a/frontend-web/src/strategy-components/api/task-buttons.ts b/frontend-web/src/strategy-components/api/task-buttons.ts new file mode 100644 index 00000000..4e651388 --- /dev/null +++ b/frontend-web/src/strategy-components/api/task-buttons.ts @@ -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(res: Response, ctx: string): Promise { + 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 { + 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 { + 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] }), + }), + }; +} 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 8813e37c..91a337f0 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -1,20 +1,22 @@ /** - * 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。 + * 任务深链的「任务工作区」:一个任务挂多个对话(1:N)。三种业务共用本布局: + * - rwfx(任务FX,完整版):左对话列表 + 任务操作(编辑/下一步/清除记录)+ 跳转三情分析按钮; + * 右侧问答带「目的树闸门」(AgentChatPage taskGate)。 + * - 3qfx(三情分析)/ xdfx(行动分析)(``lite`` 精简版):只有左对话列表(按 taskId + agentId 隔离), + * 无任务操作、无闸门;新建对话直接带任务开场信息自动发出(AgentChatPage taskBind)。 * - * 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」; - * 右下角 = 常驻浮动操作条(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走; - * 右侧 = 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。 + * 左侧顶部 = 当前任务卡片(任务名称 + 简介,来自登录深链暂存的 copTaskDetail)+「新建对话」; + * 右侧 = 渲染选中的对话。 * - * - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。 - * - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。 - * - 清除任务记录:仅调 delete 接口(聊天对话保留)。 - * - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。 - * 见 [[task-purpose]] / [[task-deeplink-config]]。 + * - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 + * - 切换/新建时 AgentChatPage 会停掉老对话的服务端 run。 + * - (rwfx)清除任务记录:仅调 delete 接口(聊天对话保留);编辑/下一步:window.open B 系统地址。 + * 见 [[task-purpose]] / [[task-deeplink]] / [[task-deeplink-config]]。 */ 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"; @@ -26,24 +28,66 @@ 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 { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config"; + DEFAULT_TASK_DEEPLINK_CONFIG, + fetchTaskDeeplinkConfig, + type TaskDeeplinkConfig, +} from "@/core/auth/task-deeplink-config"; +import { + TASK_BUSINESSES, + 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; chatsBase: string } { +function useChatsBase(taskBase: string): { taskId: string; agentId: string; chatsBase: string } { const { agent_id } = useParams<{ agent_id: string }>(); + const agentId = agent_id ?? ""; const taskId = readTaskContext()?.taskId ?? ""; - return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` }; + return { taskId, agentId, chatsBase: `${taskBase}/agent/${agentId}/chats` }; +} + +/** + * 左侧顶部「当前任务」卡片:展示登录深链查询出的任务名称 + 简介(留存在 sessionStorage 的 + * copTaskDetail)。三种业务工作区都展示,放在「新建对话」上面。无任务详情时不渲染。 + */ +function TaskDetailHeader() { + const detail = useMemo(() => readCopTaskDetail(), []); + const name = desensitize((detail?.taskName ?? "").trim()); + const brief = desensitize(((detail?.overview ?? "").trim() || (detail?.taskContent ?? "").trim())); + if (!name && !brief) return null; + return ( +
+
当前任务
+ {name && ( +
+ {name} +
+ )} + {brief && ( +
+ {brief} +
+ )} +
+ ); } /** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */ @@ -53,16 +97,111 @@ function parseThreadId(pathname: string, chatsBase: string): string | null { return seg && seg !== "new" ? seg : null; } -/** 左侧任务对话列表 +「新建对话」。 */ -function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) { +/** + * 底部「快捷跳转」栏:横跨整个工作区底部,按配置顺序渲染该业务启用的跳转按钮(按钮管理里排序)。 + * 后端为准,整张表为空才回退种子默认;地址依赖实时深链配置,故把配置拉进 state。见 [[task-buttons]]。 + */ +function TaskJumpBar({ business, taskId }: { business: TaskBusiness; taskId: string }) { + const navigate = useNavigate(); + const [cfg, setCfg] = useState(() => 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); + // xdfx:深链原始「行动id」(与解析后的任务id 不同才算真行动id),弹框据此换回任务id 再查目的。 + const ctxActionId = readTaskContext()?.actionId; + const purposeActionId = + business === "xdfx" && ctxActionId && ctxActionId !== taskId ? ctxActionId : undefined; + + 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 ( +
+ {jumpButtons.map((btn) => ( + + ))} + +
+ ); +} + +/** + * 左侧任务对话列表:顶部「当前任务」卡片 +「新建对话」;下方对话列表(1:N)。 + * 跳转按钮已移到工作区底部「快捷跳转」栏(见 ``TaskJumpBar``)。 + */ +function TaskConversationsList({ + taskId, + agentId, + chatsBase, + business, + lite, +}: { + taskId: string; + agentId: string; + chatsBase: string; + business: TaskBusiness; + lite?: boolean; +}) { const { pathname } = useLocation(); const navigate = useNavigate(); - const { threads, isLoading } = useTaskThreads(taskId); + const { threads, isLoading } = useTaskThreads(taskId, agentId); const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]); + const businessName = desensitize(TASK_BUSINESSES.find((b) => b.code === business)?.name ?? ""); return (
+ {/* 左上角标明当前是哪个分析页面 */} +
+ + {businessName} +
+
任务对话
@@ -78,6 +217,7 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas > 新建对话 + {!lite && }
@@ -114,24 +254,18 @@ function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBas )) )}
-
); } /** - * 左侧任务栏底部操作区:编辑 / 下一步 / 清除任务记录。 - * 跟随任务对话面板布局,不再覆盖右侧聊天输入框。 + * 「清除记录」按钮(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 { @@ -148,44 +282,16 @@ function TaskActionsPanel({ taskId }: { taskId: string }) { return ( <> -
-
-
- 任务操作 -
-
- - - -
-
-
+ !clearing && setClearOpen(o)}> @@ -209,28 +315,44 @@ function TaskActionsPanel({ taskId }: { taskId: string }) { ); } -/** 任务工作区布局:左列表 + 右 Outlet。 */ -export function TaskFxWorkspace({ taskBase }: { taskBase: string }) { - const { taskId, chatsBase } = useChatsBase(taskBase); - // 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。 - useEffect(() => { - void fetchTaskDeeplinkConfig(); - }, []); +/** + * 任务工作区布局:左列表 + 右 Outlet。 + * ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。 + */ +export function TaskFxWorkspace({ + taskBase, + business, + lite, +}: { + taskBase: string; + business: TaskBusiness; + lite?: boolean; +}) { + const { taskId, agentId, chatsBase } = useChatsBase(taskBase); return ( -
- -
- +
+
+ +
+ +
+
); } -/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(走闸门)。 */ +/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) { - const { taskId, chatsBase } = useChatsBase(taskBase); + const { taskId, agentId, chatsBase } = useChatsBase(taskBase); const navigate = useNavigate(); - const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId); + const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId, agentId); useEffect(() => { if (isLoading) return; @@ -248,12 +370,35 @@ export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) { ); } -/** 右侧对话宿主: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 ( - + + + ); +} + +/** 右侧对话宿主(3qfx/xdfx):AgentChatPage taskBind → 仅 1:N 绑定,新建对话直接带开场信息自动发出。 */ +export function TaskBindChat({ taskBase }: { taskBase: string }) { + const { agent_id } = useParams<{ agent_id: string }>(); + return ( + + ); } diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx index 802d85d7..e1b052b5 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx @@ -10,8 +10,8 @@ * onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。 */ -import { useEffect, useMemo, useState } from "react"; -import { 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"; @@ -30,10 +30,12 @@ import { type PurposeTreeNode, type TaskPurposeResult, } from "@/core/auth/task-purpose"; +import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink"; +import { desensitize } from "@/lib/desensitize"; 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); @@ -57,7 +59,7 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className? 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} + {desensitize(h)} ))} @@ -71,9 +73,9 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className? {cell ? ( <> - {cell.text || "(空)"} + {cell.text ? desensitize(cell.text) : "(空)"} {cell.reason ? ( - 理由:{cell.reason} + 理由:{desensitize(cell.reason)} ) : null} ) : ( @@ -90,26 +92,103 @@ function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className? ); } +/** + * 独立的「任务目的弹框」:按需拉取目的树并放大展示。供底部「快捷跳转」栏的 ``purpose`` 类型 + * 按钮复用(任意页面可配置「弹框查看」)。打开时才拉取。 + */ +export function TaskPurposeDialog({ + taskId, + open, + onOpenChange, + resolveFromActionId, +}: { + taskId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + /** xdfx:深链原始「行动id」。提供时弹框先用它换出真正「任务id」再查目的树。 */ + resolveFromActionId?: string; +}) { + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + const [resolvedTaskId, setResolvedTaskId] = useState(taskId); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + (async () => { + try { + // xdfx:行动id 换任务id 再查目的树(与登录时的解析一致);无行动id 时直接用任务id。 + let tid = taskId; + if (resolveFromActionId) { + tid = (await fetchTaskIdFromAction(resolveFromActionId)) || taskId; + } + if (cancelled) return; + setResolvedTaskId(tid); + const r = await fetchTaskPurposeDetail(tid); + if (!cancelled) setResult(r); + } catch { + if (!cancelled) setResult({ tree: [], hasData: false }); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, taskId, resolveFromActionId]); + + return ( + + + + 任务目的(任务 {resolvedTaskId}) + + {loading ? ( +
+ 正在查询任务目的… +
+ ) : ( + + )} +
+
+ ); +} + export function TaskPurposePanel({ taskId, onStart, + autoStartWhenEmpty, }: { taskId: string; /** 闸门通过(无数据 / 覆盖成功),开始问答。 */ onStart: () => void; + /** rwfx:查询任务目的为空数据时,自动开始第一次问答(跳过手动点「开始问答」)。 */ + autoStartWhenEmpty?: boolean; }) { const [loading, setLoading] = useState(true); const [result, setResult] = useState(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 }); @@ -120,6 +199,8 @@ export function TaskPurposePanel({ return () => { cancelled = true; }; + // onStart/autoStartWhenEmpty 在一次新建会话内稳定;仅按 taskId 重新拉取。 + // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId]); const handleStart = () => { @@ -151,7 +232,19 @@ export function TaskPurposePanel({ return (
- 任务目的(任务 {taskId}) +
{hasTree && (
- {loading ? ( -
- 正在查询任务目的… -
- ) : ( - - )} + {!collapsed && + (loading ? ( +
+ 正在查询任务目的… +
+ ) : ( + + ))} {/* 弹框查看:放大显示完整表格 */} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index f85cc64b..4985fc2d 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -256,6 +256,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. | | **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. | | **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.py`。 | +| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), enabled, sort_order}`. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. | | **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). | Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 5936e04f..a32575ef 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -72,6 +72,7 @@ from app.gateway.routers import ( suggestions, system_settings, tags, + task_buttons, thread_runs, tool_metrics, thread_shares, @@ -1360,6 +1361,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions app.include_router(dashboard_sessions.router) + # Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons + app.include_router(task_buttons.router) + # Standalone parallel multi-agent panel: fan-out streaming + saved tasks app.include_router(parallel_agents.router) app.include_router(parallel_agent_tasks.router) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 6232224f..ef8141db 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -275,6 +275,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.dashboard_session_store = make_dashboard_session_store(sf) + from deerflow.persistence.task_buttons import make_task_button_store + + app.state.task_button_store = make_task_button_store(sf) + from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf) diff --git a/offline-backend-20260512/backend/app/gateway/routers/task_buttons.py b/offline-backend-20260512/backend/app/gateway/routers/task_buttons.py new file mode 100644 index 00000000..73b4f44e --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/task_buttons.py @@ -0,0 +1,104 @@ +"""Task-workspace configurable jump-button API (按钮管理). + +The task deep-link workspaces (rwfx / 3qfx / xdfx, see the frontend +``TaskFxWorkspace``) render configurable jump buttons in their left task panel. +Their default targets historically lived in ``config.yaml`` ``task_deeplink`` and +their names were hardcoded; this router persists a **global / shared** button +set the frontend renders instead, so an admin can rename / retarget / add / +remove jump buttons per business. + +When the table is empty (fresh deployment, never saved) the frontend falls back +to code-defined seed defaults built from the live task-deeplink config; once +saved, this table is the source of truth. + +Reads are open to any authenticated user (the workspace needs them); the whole +button set is replaced atomically and only by an admin. + +Routes (prefix ``/api/task-buttons``): + GET "" list all buttons + PUT "" replace the entire button set (admin) +""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from app.gateway.deps import get_current_user, get_optional_user_from_request +from deerflow.persistence.task_buttons import TaskButtonStore + +router = APIRouter(prefix="/api/task-buttons", tags=["task-buttons"]) + + +# ── schemas ────────────────────────────────────────────────────────────── + + +class TaskButton(BaseModel): + id: str = Field(..., min_length=1, max_length=128) + business: str = Field(..., min_length=1, max_length=32) + label: str = Field(default="", max_length=255) + link_type: str = Field(default="url", max_length=16) + target: str = Field(default="", max_length=1024) + append_task_id: bool = True + # 追加的查询参数名(如 id / task_id)。 + id_param: str = Field(default="id", max_length=64) + # task=任务id(默认)/ action=行动id(仅 xdfx 二者不同)。 + id_kind: str = Field(default="task", max_length=16) + enabled: bool = True + sort_order: int = 0 + + +class TaskButtonResponse(TaskButton): + updated_by: str | None = None + updated_at: datetime | str | None = None + + +class TaskButtonListResponse(BaseModel): + buttons: list[TaskButtonResponse] + + +class TaskButtonReplaceRequest(BaseModel): + buttons: list[TaskButton] = Field(default_factory=list) + + +# ── helpers ────────────────────────────────────────────────────────────── + + +def _get_store(request: Request) -> TaskButtonStore: + store = getattr(request.app.state, "task_button_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Task button store not available") + return store + + +async def _require_admin(request: Request) -> None: + """Reject non-admins. Lets the call through when auth is disabled.""" + user = await get_optional_user_from_request(request) + if user is None: + return + if getattr(user, "system_role", None) != "admin": + raise HTTPException(status_code=403, detail="按钮管理仅限管理员") + + +# ── routes ─────────────────────────────────────────────────────────────── + + +@router.get("", response_model=TaskButtonListResponse) +async def list_task_buttons(request: Request) -> TaskButtonListResponse: + store = _get_store(request) + rows = await store.list_buttons() + return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows]) + + +@router.put("", response_model=TaskButtonListResponse) +async def replace_task_buttons(request: Request, body: TaskButtonReplaceRequest) -> TaskButtonListResponse: + await _require_admin(request) + store = _get_store(request) + user_id = await get_current_user(request) + rows = await store.replace_all( + [b.model_dump() for b in body.buttons], + updated_by=user_id, + ) + return TaskButtonListResponse(buttons=[TaskButtonResponse(**r) for r in rows]) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 997d7e2c..b12eec02 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -420,13 +420,18 @@ log_system: label: '日志系统' task_deeplink: - # 线上接口未通时用假数据;两个开关互相独立。 + # 线上接口未通时用假数据;三个开关互相独立。 test: true # 目的树查询走 mock.purpose_detail 假数据 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" 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" + # rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。 + detail_3q_url: "" mock: # test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。 purpose_detail: @@ -455,3 +460,15 @@ task_deeplink: delete: message: "行动已经开始策划,不可覆盖" success: true + # action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。 + # 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。 + action_detail: + state: "200" + msg: "操作成功!" + data: + id: 27354 + purposeId: 2850 + taskId: 2055 + actionName: "" + actionStatus: 25 + narration: "" 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 5ebc2f13..e5089c2a 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 @@ -1,12 +1,14 @@ -"""任务深链(``goPath=rwfx``)的「目的树闸门 + 跳转」配置。 +"""任务深链(``goPath=rwfx`` / ``xdfx``)的「目的树闸门 + 行动→任务解析 + 跳转」配置。 前端(登录深链流程)通过公开只读接口 ``GET /api/public/config/task-deeplink`` -读取这一段:所有外部地址都是**完整 URL**(调用时仅追加 ``?taskId=`` / ``?id=``), +读取这一段:所有外部地址都是**完整 URL**(调用时仅追加 ``?taskId=`` / ``?id=`` 或 ``/{actionId}`` 路径段), 随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。 测试开关(线上接口未通时用假数据,互相独立): - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。 - ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。 + - ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。 + xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。 """ from __future__ import annotations @@ -29,10 +31,22 @@ class TaskDeeplinkConfig(BaseModel): default=False, description="为真时前端删除接口走 mock.delete 假数据(删除接口未开发完时打开)", ) + action_test: bool = Field( + default=False, + description="为真时前端「行动→任务」解析走 mock.action_detail 假数据(不打真实接口)", + ) purpose_detail_url: str = Field( default="", description="目的树查询完整 URL,调用时追加 ?taskId=", ) + action_detail_url: str = Field( + default="", + description="行动详情接口基址(xdfx 行动→任务),调用时追加 /{actionId} 路径段;返回 data.taskId 为真正任务id", + ) + detail_3q_url: str = Field( + default="", + description="「3q详情」详情页 URL(rwfx 目的树面板的链接),跳转时追加 ?id=(任务id)", + ) delete_url: str = Field( default="", description="删除完整 URL,调用时追加 ?taskId=", @@ -47,5 +61,5 @@ class TaskDeeplinkConfig(BaseModel): ) mock: dict[str, Any] = Field( default_factory=dict, - description="test/delete_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}}", + description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}}", ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index de29be84..d055c2a0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -66,6 +66,7 @@ from deerflow.persistence.scheduled_tasks.model import ( SchedulerThreadRow, ) from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow +from deerflow.persistence.task_buttons.model import TaskButtonRow from deerflow.persistence.tags.model import TagAssignmentRow, TagRow from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.embed_sessions.model import EmbedSessionRow @@ -123,6 +124,7 @@ __all__ = [ "SkillFavoriteRow", "TagAssignmentRow", "TagRow", + "TaskButtonRow", "EmbedSessionRow", "ThreadMetaRow", "ThreadShareRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/__init__.py new file mode 100644 index 00000000..ba3e2a19 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/__init__.py @@ -0,0 +1,22 @@ +"""Task-workspace configurable jump-button persistence (按钮管理).""" + +from __future__ import annotations + +from deerflow.persistence.task_buttons.base import TaskButtonStore +from deerflow.persistence.task_buttons.memory import MemoryTaskButtonStore +from deerflow.persistence.task_buttons.model import TaskButtonRow +from deerflow.persistence.task_buttons.sql import TaskButtonRepository + +__all__ = [ + "MemoryTaskButtonStore", + "TaskButtonRepository", + "TaskButtonRow", + "TaskButtonStore", + "make_task_button_store", +] + + +def make_task_button_store(session_factory): + if session_factory is None: + return MemoryTaskButtonStore() + return TaskButtonRepository(session_factory) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/base.py new file mode 100644 index 00000000..a98ed462 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/base.py @@ -0,0 +1,30 @@ +"""Abstract task-workspace button persistence API. + +Global / shared store: no ``user_id`` scoping. Reads are open to any caller; the +router gates writes behind admin. ``updated_by`` is audit-only. + +The whole button set is treated as one document: the editor computes the full +desired layout and replaces every row atomically via :meth:`replace_all`, so the +stored set never drifts out of sync with what the admin sees. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class TaskButtonStore(ABC): + @abstractmethod + async def list_buttons(self) -> list[dict[str, Any]]: + """Return every stored button row (order unspecified).""" + raise NotImplementedError + + @abstractmethod + async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: + """Atomically replace the entire button set with ``rows``. + + Each row is ``{id, business, label, link_type, target, append_task_id, + enabled, sort_order}``. Returns the persisted rows. + """ + raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/memory.py new file mode 100644 index 00000000..a775e169 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/memory.py @@ -0,0 +1,42 @@ +"""In-memory task-button store (used when no DB is configured / tests).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from deerflow.persistence.task_buttons.base import TaskButtonStore + + +def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]: + return { + "id": str(data["id"]), + "business": str(data.get("business") or ""), + "label": str(data.get("label") or ""), + "link_type": str(data.get("link_type") or "url"), + "target": str(data.get("target") or ""), + "append_task_id": bool(data.get("append_task_id", True)), + "id_param": str(data.get("id_param") or "id"), + "id_kind": str(data.get("id_kind") or "task"), + "enabled": bool(data.get("enabled", True)), + "sort_order": int(data.get("sort_order", 0)), + "updated_by": updated_by, + "updated_at": datetime.now(UTC).isoformat(), + } + + +class MemoryTaskButtonStore(TaskButtonStore): + def __init__(self) -> None: + self._rows: dict[str, dict[str, Any]] = {} + + async def list_buttons(self) -> list[dict[str, Any]]: + return [dict(r) for r in sorted(self._rows.values(), key=lambda r: r.get("sort_order", 0))] + + async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: + self._rows = {} + for r in rows: + row_id = r.get("id") + if not row_id: + continue + self._rows[str(row_id)] = _normalize(r, updated_by) + return [dict(r) for r in self._rows.values()] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/model.py new file mode 100644 index 00000000..f68f413a --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/model.py @@ -0,0 +1,54 @@ +"""ORM model for task-workspace configurable jump buttons (按钮管理). + +Global / shared — there is no ``user_id`` scoping; every account sees the same +buttons. The task deep-link workspaces (rwfx / 3qfx / xdfx, see +``TaskFxWorkspace``) render the enabled buttons for their ``business`` in the +left task panel. Each row is one button: + +- ``business`` — which workspace the button belongs to (``rwfx`` / ``3qfx`` / + ``xdfx``). +- ``label`` — display name. +- ``link_type`` — ``url`` (external, opened in a new tab) or ``business`` + (internal switch to another deep-link business). +- ``target`` — the full URL (``url``) or the target business code (``business``). +- ``append_task_id`` — for ``url`` buttons, whether to append ``?id={taskId}``. +- ``enabled`` — 停用 parks a button without deleting it. +- ``sort_order`` — position among the business's buttons. + +When the whole table is empty (fresh deployment, never saved) the frontend +falls back to code-defined seed defaults built from the live task-deeplink +config; once saved, this table is the source of truth. ``updated_by`` is +audit-only. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base +from deerflow.persistence.types import BeijingDateTime + + +class TaskButtonRow(Base): + __tablename__ = "task_buttons" + + id: Mapped[str] = mapped_column(String(128), primary_key=True) + business: Mapped[str] = mapped_column(String(32), index=True) + label: Mapped[str] = mapped_column(String(255), default="") + link_type: Mapped[str] = mapped_column(String(16), default="url") + target: Mapped[str] = mapped_column(String(1024), default="") + append_task_id: Mapped[bool] = mapped_column(Boolean, default=True) + # 追加的查询参数名(如 ``id`` / ``task_id``)。可空,读时归一化为 ``id``。 + id_param: Mapped[str | None] = mapped_column(String(64), default="id") + # 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同:深链原始 + # ?taskId= 实为行动id,任务id 由 action 接口解析)。可空,读时归一化为 ``task``。 + id_kind: Mapped[str | None] = mapped_column(String(16), default="task") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + updated_by: Mapped[str | None] = mapped_column(String(64)) + updated_at: Mapped[datetime] = mapped_column( + BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC) + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/sql.py new file mode 100644 index 00000000..be498f1c --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/task_buttons/sql.py @@ -0,0 +1,66 @@ +"""SQLAlchemy-backed task-button store (global / shared).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.task_buttons.base import TaskButtonStore +from deerflow.persistence.task_buttons.model import TaskButtonRow + + +def _to_dict(row: TaskButtonRow) -> dict[str, Any]: + data = row.to_dict() + value = data.get("updated_at") + if isinstance(value, datetime): + data["updated_at"] = value.isoformat() + # 旧行 / NULL 归一化为默认值。 + data["id_param"] = data.get("id_param") or "id" + data["id_kind"] = data.get("id_kind") or "task" + return data + + +def _row_from(data: dict[str, Any], updated_by: str | None) -> TaskButtonRow: + return TaskButtonRow( + id=str(data["id"]), + business=str(data.get("business") or ""), + label=str(data.get("label") or ""), + link_type=str(data.get("link_type") or "url"), + target=str(data.get("target") or ""), + append_task_id=bool(data.get("append_task_id", True)), + id_param=str(data.get("id_param") or "id"), + id_kind=str(data.get("id_kind") or "task"), + enabled=bool(data.get("enabled", True)), + sort_order=int(data.get("sort_order", 0)), + updated_by=updated_by, + ) + + +class TaskButtonRepository(TaskButtonStore): + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def list_buttons(self) -> list[dict[str, Any]]: + stmt = select(TaskButtonRow).order_by(TaskButtonRow.sort_order.asc()) + async with self._sf() as session: + result = await session.execute(stmt) + return [_to_dict(row) for row in result.scalars()] + + async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]: + # De-dupe by id (last wins) so a malformed payload can't violate the PK. + by_id: dict[str, dict[str, Any]] = {} + for r in rows: + row_id = r.get("id") + if row_id: + by_id[str(row_id)] = r + async with self._sf() as session: + await session.execute(delete(TaskButtonRow)) + new_rows = [_row_from(r, updated_by) for r in by_id.values()] + session.add_all(new_rows) + await session.commit() + for row in new_rows: + await session.refresh(row) + return [_to_dict(row) for row in new_rows] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py index 0557beb7..f5fa8614 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py @@ -34,6 +34,9 @@ class MemoryThreadMetaStore(ThreadMetaStore): if item is None: return None record = dict(item.value) + # 任务深链对话(metadata.taskId)全局共享:跳过 owner 校验。 + if (record.get("metadata") or {}).get("taskId"): + return record if resolved is not None and record.get("user_id") != resolved: return None return record @@ -78,6 +81,9 @@ class MemoryThreadMetaStore(ThreadMetaStore): query: str | None = None, ) -> list[dict]: resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search") + # 任务深链对话(按 metadata.taskId 过滤)全局共享、不区分用户:跳过 owner 过滤。 + if metadata and metadata.get("taskId"): + resolved_user_id = None filter_dict: dict[str, Any] = {} if metadata: filter_dict.update(metadata) @@ -137,6 +143,9 @@ class MemoryThreadMetaStore(ThreadMetaStore): ) -> int: """Count threads matching the same filters as :meth:`search`.""" resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.count") + # 任务深链对话(按 metadata.taskId 过滤)全局共享、不区分用户:跳过 owner 过滤。 + if metadata and metadata.get("taskId"): + resolved_user_id = None filter_dict: dict[str, Any] = {} if metadata: filter_dict.update(metadata) @@ -174,6 +183,9 @@ class MemoryThreadMetaStore(ThreadMetaStore): item = await self._store.aget(THREADS_NS, thread_id) if item is None: return not require_existing + # 任务深链对话(metadata.taskId)全局共享、不区分用户:任何登录用户都可读写/续聊。 + if (item.value.get("metadata") or {}).get("taskId"): + return True record_user_id = item.value.get("user_id") if record_user_id is None: return True diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py index b701d7cd..780a3182 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py @@ -97,6 +97,9 @@ class ThreadMetaRepository(ThreadMetaStore): row = await session.get(ThreadMetaRow, thread_id) if row is None: return not require_existing + # 任务深链对话(metadata.taskId)全局共享、不区分用户:任何登录用户都可读写/续聊。 + if (row.metadata_json or {}).get("taskId"): + return True if row.user_id is None: return True return row.user_id == user_id @@ -118,6 +121,9 @@ class ThreadMetaRepository(ThreadMetaStore): context. Pass ``user_id=None`` to bypass (migration/CLI). """ resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search") + # 任务深链对话(按 metadata.taskId 过滤)全局共享、不区分用户:跳过 owner 过滤。 + if metadata and metadata.get("taskId"): + resolved_user_id = None # thread_id tie-breaker keeps OFFSET pagination stable when many rows # share the same updated_at (bulk imports, scheduler bursts). stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc()) @@ -178,6 +184,9 @@ class ThreadMetaRepository(ThreadMetaStore): ) -> int: """Count threads matching the same filters as :meth:`search`.""" resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.count") + # 任务深链对话(按 metadata.taskId 过滤)全局共享、不区分用户:跳过 owner 过滤。 + if metadata and metadata.get("taskId"): + resolved_user_id = None conditions = [] if resolved_user_id is not None: conditions.append(ThreadMetaRow.user_id == resolved_user_id) @@ -224,7 +233,12 @@ class ThreadMetaRepository(ThreadMetaStore): if resolved_user_id is None: return True # explicit bypass row = await session.get(ThreadMetaRow, thread_id) - return row is not None and row.user_id == resolved_user_id + if row is None: + return False + # 任务深链对话(metadata.taskId)全局共享:任何用户都可更新标题/状态/updated_at(续聊置顶)。 + if (row.metadata_json or {}).get("taskId"): + return True + return row.user_id == resolved_user_id async def update_display_name( self, @@ -287,7 +301,9 @@ class ThreadMetaRepository(ThreadMetaStore): row = await session.get(ThreadMetaRow, thread_id) if row is None: return - if resolved_user_id is not None and row.user_id != resolved_user_id: + # 任务深链对话(metadata.taskId)全局共享:跳过 owner 校验。 + task_shared = bool((row.metadata_json or {}).get("taskId")) + if resolved_user_id is not None and not task_shared and row.user_id != resolved_user_id: return merged = dict(row.metadata_json or {}) merged.update(metadata) @@ -306,7 +322,9 @@ class ThreadMetaRepository(ThreadMetaStore): row = await session.get(ThreadMetaRow, thread_id) if row is None: return - if resolved_user_id is not None and row.user_id != resolved_user_id: + # 任务深链对话(metadata.taskId)全局共享:任何用户都可删除。 + task_shared = bool((row.metadata_json or {}).get("taskId")) + if resolved_user_id is not None and not task_shared and row.user_id != resolved_user_id: return await session.delete(row) await session.commit() diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 94cb54a0..5c70686c 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -330,13 +330,18 @@ summarization: # GET /api/public/config/task-deeplink 读取。地址都是完整 URL(调用时仅追加 # ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。 task_deeplink: - # 线上接口未通时用假数据;两个开关互相独立。 + # 线上接口未通时用假数据;三个开关互相独立。 test: true # 目的树查询走 mock.purpose_detail 假数据 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" 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" + # rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。 + detail_3q_url: "" mock: # test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。 purpose_detail: @@ -365,3 +370,15 @@ task_deeplink: delete: message: "行动已经开始策划,不可覆盖" success: true + # action_test=true 时原样返回给前端(结构同真实 /consumer/plan/action/{actionId} 返回)。 + # 前端取 data.taskId 作为真正的「任务id」(xdfx 深链 ?taskId= 传入的是 data.id 这个行动id)。 + action_detail: + state: "200" + msg: "操作成功!" + data: + id: 27354 + purposeId: 2850 + taskId: 2055 + actionName: "" + actionStatus: 25 + narration: ""