diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 6aff6410..205eb9a1 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -119,7 +119,8 @@ border: 1px solid; border-color: rgba(43, 143, 254, 0.6); border-radius: 4px; - backdrop-filter: blur(2px); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); position: absolute; left: 108px; top: 0; diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index b06ccd8b..b959d0b2 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -207,6 +207,12 @@ export const sidebarMenuItems: MenuItem[] = [ icon: Settings, children: [ { id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" }, + { + id: "mgmt-business-chain-mapping", + label: "业务链条管理", + icon: Network, + path: "/page/strategy/business-chain-mapping", + }, { id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" }, { id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, { diff --git a/frontend-web/src/pages/BusinessChainMappingPage.tsx b/frontend-web/src/pages/BusinessChainMappingPage.tsx new file mode 100644 index 00000000..a3d05703 --- /dev/null +++ b/frontend-web/src/pages/BusinessChainMappingPage.tsx @@ -0,0 +1,273 @@ +/** + * 业务链条管理页 —— 把固定业务码(3Q/6BF/7BF/8BF)绑定到「智能体」+「业务链」。 + * + * 表格三列:业务(只读)/ 智能体 / 业务链。编辑模式下后两列为可搜索下拉: + * - 智能体来自 `/api/agents` + * - 业务链来自 `/api/roundtable-chains?scope=public`(只列公共链,保证全局共享时各账号都能解析) + * 保存调 `PUT /api/business-mapping/{code}`。映射全局共享、无用户隔离。 + */ +import { useEffect, useMemo, useState } from "react"; +import { Loading, Select } from "tdesign-react"; +import { Link2, Pencil, Save, Users, X } from "lucide-react"; +import { toast } from "sonner"; + +import { useTheme } from "@/contexts/ThemeContext"; +import { listAgents } from "@/core/agents/api"; +import type { Agent } from "@/core/agents/types"; +import { listChains } from "@/roundtable-planning/api/chains"; +import type { BusinessChain } from "@/roundtable-planning/lib/business-chain"; +import { + listBusinessMappings, + updateBusinessMapping, + type BusinessMapping, +} from "@/strategy-components/api/business-mapping"; + +interface DraftSelection { + agentId: string | null; + chainId: string | null; +} + +export default function BusinessChainMappingPage() { + const { theme } = useTheme(); + const isDark = theme === "dark"; + + const [rows, setRows] = useState([]); + const [agents, setAgents] = useState([]); + const [chains, setChains] = useState([]); + const [loading, setLoading] = useState(true); + const [editingCode, setEditingCode] = useState(null); + const [draft, setDraft] = useState({ agentId: null, chainId: null }); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + try { + const [mappingRes, agentRes, chainRes] = await Promise.allSettled([ + listBusinessMappings(), + listAgents(), + listChains("public"), + ]); + if (cancelled) return; + if (mappingRes.status === "fulfilled") setRows(mappingRes.value); + else toast.error(`加载业务映射失败:${mappingRes.reason}`); + if (agentRes.status === "fulfilled") setAgents(agentRes.value); + if (chainRes.status === "fulfilled") setChains(chainRes.value); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const agentOptions = useMemo( + () => agents.map((a) => ({ label: a.name || a.id, value: a.id })), + [agents], + ); + const chainOptions = useMemo( + () => chains.map((c) => ({ label: c.title || c.id, value: c.id })), + [chains], + ); + + const beginEdit = (row: BusinessMapping) => { + setEditingCode(row.businessCode); + setDraft({ agentId: row.agentId, chainId: row.chainId }); + }; + + const cancelEdit = () => { + setEditingCode(null); + setDraft({ agentId: null, chainId: null }); + }; + + const handleSave = async (row: BusinessMapping) => { + setSaving(true); + try { + const agentName = draft.agentId + ? agents.find((a) => a.id === draft.agentId)?.name ?? row.agentName ?? null + : null; + const chainTitle = draft.chainId + ? chains.find((c) => c.id === draft.chainId)?.title ?? row.chainTitle ?? null + : null; + const updated = await updateBusinessMapping(row.businessCode, { + agentId: draft.agentId, + agentName, + chainId: draft.chainId, + chainTitle, + }); + if (!updated) { + toast.error(`业务「${row.label}」不存在,保存失败`); + return; + } + setRows((prev) => prev.map((r) => (r.businessCode === row.businessCode ? updated : r))); + toast.success(`已保存「${row.label}」的配置`); + cancelEdit(); + } catch (err) { + toast.error(`保存失败:${(err as Error).message}`); + } finally { + setSaving(false); + } + }; + + const shellClass = isDark + ? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100" + : "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]"; + const cardClass = isDark + ? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]" + : "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]"; + const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]"; + const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]"; + const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]"; + const codeBadge = isDark + ? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]" + : "bg-[#FFF1F0] text-[#CF1322]"; + + return ( +
+
+
+

+ 业务链条管理 +

+

+ 为每个业务绑定智能体与业务链。多智能体会商从对应业务自动取用此处配置的业务链。业务链仅可选择「公共」链条。 +

+
+ + +
+ + + + + + + + + + + {rows.length === 0 && !loading ? ( + + + + ) : ( + rows.map((row) => { + const editing = editingCode === row.businessCode; + return ( + + {/* 业务(只读) */} + + + {/* 智能体 */} + + + {/* 业务链 */} + + + {/* 操作 */} + + + ); + }) + )} + +
业务智能体业务链操作
+ 暂无业务数据 +
+
+ + {row.label} + + + {row.businessCode} + +
+
+ {editing ? ( + + {editing ? ( + + {editing ? ( +
+ + +
+ ) : ( + + )} +
+
+
+
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 07979f95..5f0129ed 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -20,6 +20,7 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni import "@/roundtable-planning/styles/roundtable-planning.css"; import AgentChatPage from "./AgentChatPage"; import AgentsPage from "./AgentsPage"; +import BusinessChainMappingPage from "./BusinessChainMappingPage"; import ChatPage from "./ChatPage"; import ChatsPage from "./ChatsPage"; import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage"; @@ -172,6 +173,7 @@ export default function StrategyRoutes() { } /> } /> } /> + } /> = ({ [artifactsBySeat], ); + // 只保留「有产出文件」的席位;加载中 / 出错的席位仍保留以展示其状态。 + // 全部席位都没有文件(且都已加载完成、无错误)时,整块「生成的报告文件」隐藏。 + const seatsWithFiles = useMemo( + () => artifactsBySeat.filter((s) => s.loading || s.error || s.files.length > 0), + [artifactsBySeat], + ); + const toggleSeat = useCallback((key: string) => { setExpandedSeats((prev) => { const next = new Set(prev); @@ -424,21 +431,20 @@ export const HighFidelityReport: React.FC = ({ )} - {/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */} -
-

- - 生成的报告文件 - - {totalFileCount} - -

+ {/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览。 + 仅当存在「有产出文件 / 加载中 / 出错」的席位时才渲染;全部席位无文件则整块隐藏。 */} + {seatsWithFiles.length > 0 && ( +
+

+ + 生成的报告文件 + + {totalFileCount} + +

- {artifactsBySeat.length === 0 ? ( -

尚未初始化席位线程,无法列出文件。

- ) : (
- {artifactsBySeat.map((seat) => ( + {seatsWithFiles.map((seat) => (
{seat.displayName} @@ -501,8 +507,8 @@ export const HighFidelityReport: React.FC = ({
))}
- )} -
+
+ )} {/* Footer */}
diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index eac12627..d550b4a6 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -5,9 +5,12 @@ * (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按 * 链条顺序逐个派活**(见 `docs/multi-agent-business-chain-dev.md` §6)。 * - * 这里只放纯类型 / 常量,不含 React 状态或请求逻辑(CRUD 在 `api/chains.ts`)。 + * 这里放纯类型 / 常量,以及不依赖 React 的纯函数(如 `buildChainSeatSelection`); + * 不含状态或请求逻辑(CRUD 在 `api/chains.ts`)。 */ +import { buildPlanFromStages } from "./dag-types"; + /** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */ export interface ChainSeat { agent_id: string; @@ -86,6 +89,55 @@ export interface SeatSelectionResult { export const CHAIN_MIN_SEATS = 2; export const CHAIN_MAX_SEATS = 8; +/** + * 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。 + * + * 与 `BusinessChainPicker.handleUse` 同源逻辑,抽成纯函数供「自动选链」复用 + * (会商自动化串联:从任务页带 chainId 进圆桌时跳过选链弹窗,直接据此进 Step 2): + * - 仅保留候选池里仍存在的存活席位;存活不足 `CHAIN_MIN_SEATS` 返回 null(调用方回退弹窗); + * - 链条带分层、过滤存活后仍含并行 stage / 任一阶段目标 / 总控提示 → 走 `dag`(默认 fast); + * - 否则走线性 `chain`,席位顺序 = 链条顺序。 + * + * `candidates` 为 `{ agent_id, name }` 列表(一般来自 `filterRecommendCandidates(listAgents())`)。 + */ +export function buildChainSeatSelection( + chain: BusinessChain, + candidates: ReadonlyArray<{ agent_id: string; name: string }>, +): SeatSelectionResult | null { + const existingIds = new Set(candidates.map((c) => c.agent_id)); + const nameById = new Map(candidates.map((c) => [c.agent_id, c.name])); + const seatNameById = new Map(chain.seats.map((s) => [s.agent_id, s.name])); + const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id; + + const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id)); + if (survivingSeats.length < CHAIN_MIN_SEATS) return null; + + const chainMeta = { id: chain.id, title: chain.title }; + + if (chain.stages && chain.stages.length > 0) { + const surviveIds = survivingSeats.map((s) => s.agent_id); + const plan = buildPlanFromStages( + chain.stages, + surviveIds, + true, + chain.stageGoals, + chain.coordinatorPrompt, + ); + const hasParallel = plan.stages.some((st) => st.agentIds.length > 1); + const hasGoal = plan.stages.some((st) => (st.goal?.trim() ?? "") !== ""); + const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== ""; + if (hasParallel || hasGoal || hasCoord) { + const agents = plan.stages + .flatMap((st) => st.agentIds) + .map((id) => ({ agent_id: id, name: nameOf(id) })); + return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast" }; + } + } + + const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) })); + return { agents, mode: "chain", chain: chainMeta }; +} + /** * 把 `getAvatarTypeFor()` 产出的头像配色(blue/purple/emerald/amber/pink/human) * 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。 diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 1ee2c3f7..e4d75d7f 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -23,7 +23,7 @@ * 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级 */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { Activity, Check, @@ -84,14 +84,44 @@ import { type RoleEntry, type SelectedAgent, } from "@/roundtable-planning/lib/roundtable-constants"; -import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain"; +import { + buildChainSeatSelection, + type SeatSelectionResult, +} from "@/roundtable-planning/lib/business-chain"; import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types"; import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs"; +import { getChain } from "@/roundtable-planning/api/chains"; +import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend"; +import { listAgents } from "@/core/agents"; +import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; + +/** 从任务页带进来的预填(经 navigation state 传递)。见会商自动化串联(模块 C)。 */ +interface RoundtablePrefill { + /** 任务名称(注入 Step 1 首条用户消息)。 */ + taskTitle?: string; + /** 任务描述(注入 Step 1 首条用户消息)。 */ + taskGoal?: string; + /** 来源业务码(如 "6BF"):据此查业务映射拿到要自动选用的业务链。 */ + businessCode?: string; + /** 是否进入即自动开跑 Step 1 意图分析。 */ + autoStart?: boolean; +} export default function RoundtablePlanningPage() { const { theme } = useTheme(); const isDark = theme === "dark"; const navigate = useNavigate(); + const location = useLocation(); + + // ─── 会商自动化串联(模块 C):任务页带进来的预填 ───────────────── + // 只在首次渲染读一次 navigation state;随后清掉 history state,避免刷新/后退重放。 + const prefillRef = useRef( + ((location.state as { roundtablePrefill?: RoundtablePrefill } | null)?.roundtablePrefill) ?? null, + ); + // 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。 + const [autoChainResult, setAutoChainResult] = useState(null); + const autoStartedRef = useRef(false); + const prefillClearedRef = useRef(false); const { models: availableModels, isLoading: modelsLoading } = useModels(); @@ -604,6 +634,69 @@ export default function RoundtablePlanningPage() { [buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast], ); + // ─── 会商自动化串联(模块 C) ────────────────────────────────── + // 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。 + useEffect(() => { + if (prefillClearedRef.current || !prefillRef.current) return; + prefillClearedRef.current = true; + try { + window.history.replaceState({ ...window.history.state, usr: null }, ""); + } catch { + /* noop */ + } + }, []); + + // C2:带 autoStart 时,等 Step 1 意图线程就绪后自动把「任务名称/描述」作为首条用户消息发出, + // 触发任务理解智能体自动分析(只发一次)。 + useEffect(() => { + const prefill = prefillRef.current; + if (!prefill?.autoStart || autoStartedRef.current) return; + if (currentStep !== 1) return; + if (!step1.intentThreadId || step1.intentReady) return; + const title = prefill.taskTitle?.trim(); + const goal = prefill.taskGoal?.trim(); + const text = [title ? `任务名称:${title}` : "", goal ? `任务描述:${goal}` : ""] + .filter(Boolean) + .join("\n"); + if (!text) return; + autoStartedRef.current = true; + void step1.sendIntentMessage(text); + }, [currentStep, step1.intentThreadId, step1.intentReady, step1.sendIntentMessage]); + + // C3:据 prefill.businessCode 查业务映射 → 取 chainId → 拉公共链 + 候选智能体 → 构造自动选链结果。 + // 解析成功后「进入研讨」会跳过选链弹窗直接据此进 Step 2;解析失败(未配置 / 链不存在 / 席位不足) + // 静默回退到弹窗手选。 + // 不加「已启动」永久闸门 —— StrictMode 的 mount→unmount→remount 会让首次 run 在 cleanup + // 里被 cancelled,闸门又让 remount 跳过重启,导致 autoChainResult 永远设不上(与 + // useStep1Intent 同款坑)。改为:每次 setup 都重跑,靠 cancelled 丢弃过期的首次结果, + // 末次 setup 正常落地(生产无 StrictMode 只跑一次)。 + useEffect(() => { + const prefill = prefillRef.current; + if (!prefill?.businessCode) return; + let cancelled = false; + void (async () => { + try { + const mappings = await listBusinessMappings(); + const row = mappings.find((m) => m.businessCode === prefill.businessCode); + const chainId = row?.chainId; + if (!chainId) return; + const [chain, agents] = await Promise.all([ + getChain(chainId), + listAgents().catch(() => []), + ]); + if (cancelled || !chain) return; + const candidates = filterRecommendCandidates(agents); + const result = buildChainSeatSelection(chain, candidates); + if (!cancelled && result) setAutoChainResult(result); + } catch { + /* 解析失败 → 保持 null,回退弹窗手选 */ + } + })(); + return () => { + cancelled = true; + }; + }, []); + // 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。 // 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑, // 在历史记录里可见)。 @@ -903,6 +996,13 @@ export default function RoundtablePlanningPage() { triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入"); return; } + // 会商自动化串联(模块 C):若任务页带来的业务链已解析成功,跳过选链弹窗, + // 直接据该业务链进入 Step 2 研讨(用户无需再手选)。 + if (autoChainResult) { + const result = autoChainResult; + void drafts.ensureDraft(1).finally(() => handleConfirmAgents(result)); + return; + } // 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗 // (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。 void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true)); diff --git a/frontend-web/src/strategy-components/api/business-mapping.ts b/frontend-web/src/strategy-components/api/business-mapping.ts new file mode 100644 index 00000000..858e0a1c --- /dev/null +++ b/frontend-web/src/strategy-components/api/business-mapping.ts @@ -0,0 +1,87 @@ +/** + * 全局业务映射(业务码 → 智能体 / 业务链)API 客户端。 + * + * 后端:`/api/business-mapping`(见 app/gateway/routers/business_mapping.py)。 + * 全局共享、无用户隔离;只读列表 + 更新 agent / chain 两列(业务行由后端 seed 固定)。 + * snake_case ⇄ camelCase 在此边界收敛,调用方保持干净。 + */ + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +export interface BusinessMapping { + businessCode: string; + label: string; + agentId: string | null; + agentName: string | null; + chainId: string | null; + chainTitle: string | null; + sortOrder: number; +} + +/** 部分更新 —— 只发改动的字段;显式传 null 清空该字段。 */ +export interface BusinessMappingPatch { + agentId?: string | null; + agentName?: string | null; + chainId?: string | null; + chainTitle?: string | null; +} + +const BASE = () => `${getBackendBaseURL()}/api/business-mapping`; + +interface ServerMapping { + business_code: string; + label: string; + agent_id: string | null; + agent_name: string | null; + chain_id: string | null; + chain_title: string | null; + sort_order: number; +} + +function toMapping(s: ServerMapping): BusinessMapping { + return { + businessCode: s.business_code, + label: s.label ?? "", + agentId: s.agent_id ?? null, + agentName: s.agent_name ?? null, + chainId: s.chain_id ?? null, + chainTitle: s.chain_title ?? null, + sortOrder: s.sort_order ?? 0, + }; +} + +async function readJson(res: Response, ctx: string): Promise { + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`${ctx} failed (${res.status}): ${text}`); + } + return (await res.json()) as T; +} + +/** 列出全部业务映射(按展示顺序)。 */ +export async function listBusinessMappings(): Promise { + const res = await apiFetch(BASE(), { method: "GET" }); + const data = await readJson<{ mappings: ServerMapping[] }>(res, "list business mappings"); + return (data.mappings ?? []).map(toMapping); +} + +/** 更新某业务行的智能体 / 业务链选择。404 返回 null。 */ +export async function updateBusinessMapping( + businessCode: string, + patch: BusinessMappingPatch, +): Promise { + const body: Record = {}; + if (patch.agentId !== undefined) body.agent_id = patch.agentId; + if (patch.agentName !== undefined) body.agent_name = patch.agentName; + if (patch.chainId !== undefined) body.chain_id = patch.chainId; + if (patch.chainTitle !== undefined) body.chain_title = patch.chainTitle; + const res = await apiFetch(`${BASE()}/${encodeURIComponent(businessCode)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.status === 404) return null; + const data = await readJson(res, "update business mapping"); + return toMapping(data); +} diff --git a/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx index d35e6690..bccf13fb 100644 --- a/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx @@ -4,9 +4,11 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta import React, { useEffect, useRef, useState } from 'react'; import { format } from 'date-fns'; import { useNavigate } from 'react-router-dom'; -import { Col, Empty, Loading, Pagination, Progress, Row } from 'tdesign-react'; +import { Col, Loading, Pagination, Progress, Row } from 'tdesign-react'; import { requestCrudService } from '@/service/request-axios-service'; import { ActionColumn } from './ActionColumn'; +import { resolveStatusIcon } from './icons'; +import { mockActionColumns, mockTaskList } from './actionMock'; interface ActionAnalyzesProps { TaskType?: string; @@ -68,6 +70,14 @@ export const ActionAnalyzes: React.FC = ({ title, category, await taskListSearch(); } + // 接口不可用 / 返回为空时回落到本地预览数据,方便联调看效果。 + const fallbackToMock = () => { + setTaskList(mockTaskList); + setTotal(mockTaskList.length); + setActionColumnList(mockActionColumns); + if (mockTaskList[0]) setTaskId(mockTaskList[0].id); + }; + // 任务查询 const taskListSearch = async () => { const param = { @@ -76,21 +86,27 @@ export const ActionAnalyzes: React.FC = ({ title, category, pageSize: state.current.pageSize } setTaskLoading(true); - const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param); - setTaskLoading(false); - console.log('任务搜索', res); - const result = res.data; - result.forEach((item: TaskListItem) => { - item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比 - item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息 - }) - setTaskList(result || []); // 设置任务列表页 - const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数 - console.log('totalRes', totalRes) - setTotal(totalRes.data || 0); // 设置总数 - if (result.length > 0) { + try { + const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param); + const result: TaskListItem[] = Array.isArray(res?.data) ? res.data : []; + if (result.length === 0) { + fallbackToMock(); + return; + } + result.forEach((item: TaskListItem) => { + item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比 + item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息 + }) + setTaskList(result); // 设置任务列表页 + const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数 + setTotal(totalRes?.data || result.length); // 设置总数 setTaskId(result[0].id); await actionListSearch(result[0].id); + } catch (err) { + console.warn('任务搜索失败,使用本地预览数据', err); + fallbackToMock(); + } finally { + setTaskLoading(false); } } @@ -100,16 +116,23 @@ export const ActionAnalyzes: React.FC = ({ title, category, taskId: taskId, } setActionLoading(true); - const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param); - console.log('行动查询', res); - setActionLoading(false); - const result = res.data; - setActionColumnList([ - { columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] }, - { columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] }, - { columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] }, - { columnName: '已终止', icon: '已终止.svg', actionList: result.endDate || [] }, - ]); + try { + const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param); + const result = res?.data ?? {}; + const columns = [ + { columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] }, + { columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] }, + { columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] }, + { columnName: '已终止', icon: '已终止.svg', actionList: result.endDate || [] }, + ]; + const hasAny = columns.some((c) => c.actionList.length > 0); + setActionColumnList(hasAny ? columns : mockActionColumns); + } catch (err) { + console.warn('行动查询失败,使用本地预览数据', err); + setActionColumnList(mockActionColumns); + } finally { + setActionLoading(false); + } } const taskDataChange = async (taskId: number) => { @@ -151,7 +174,7 @@ export const ActionAnalyzes: React.FC = ({ title, category,
{item.taskName}
{format(new Date(item.taskStartDate), 'yyyy-MM-dd HH:mm:ss')}
- + {item?.taskStatusData?.label}
@@ -195,7 +218,11 @@ export const ActionAnalyzes: React.FC = ({ title, category, )) } { - actionColumnList.length === 0 && + actionColumnList.length === 0 && ( +
+ 暂无数据 +
+ ) }
diff --git a/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx b/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx index f890c9ef..9bcfc62f 100644 --- a/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx +++ b/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx @@ -4,8 +4,9 @@ import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizonta import React, { useEffect, useRef, useState } from 'react'; import { format } from 'date-fns'; import { useNavigate } from 'react-router-dom'; -import { Col, Empty, Pagination, Progress, Row } from 'tdesign-react'; +import { Col, Pagination, Progress, Row } from 'tdesign-react'; import { requestCrudService } from '@/service/request-axios-service'; +import { resolveStatusIcon } from './icons'; interface ActionColumnProps { TaskType?: string; @@ -66,7 +67,7 @@ export const ActionColumn: React.FC = ({ title, category, sub
- + {columnName} {actionCount}
@@ -99,13 +100,11 @@ export const ActionColumn: React.FC = ({ title, category, sub )) } { - actionList.length === 0 && - } /> + actionList.length === 0 && ( +
+ 暂无数据 +
+ ) }
diff --git a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx index d9e34a20..41c56ceb 100644 --- a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx @@ -4,10 +4,10 @@ import dayjs from 'dayjs'; import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '@/contexts/ThemeContext'; -import { useChatContext } from '@/contexts/ChatContext'; import { requestCrudService } from '@/service/request-axios-service'; import { KanbanColumn } from './KanbanColumn'; import { TaskListItem } from './TaskManagement'; +import { sentimentAnalyzeMock } from './kanbanMock'; interface TaskListPageProps { title: string; @@ -28,7 +28,6 @@ const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; const SentimentAnalyzes: React.FC = ({ title, category, subCategory }) => { const { theme } = useTheme(); - const { setStartConfig } = useChatContext(); const navigate = useNavigate(); const isDark = theme === 'dark'; @@ -80,6 +79,19 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa await taskDataChange(); }; + const applyColumnData = (taskType: ColumnType, list: TaskListItem[], total: number) => { + if (taskType === 'unanalyzed') { + setUnanalyzedTotal(total); + setUnanalyzedList(list); + } else if (taskType === 'running') { + setRunningTotal(total); + setRunningList(list); + } else { + setCompletedTotal(total); + setCompletedList(list); + } + }; + const taskListSearch = async ( taskType: ColumnType, pageNum = 1, @@ -107,18 +119,19 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa column: taskType, taskLevel: item.taskLevel || 1, })); - const total = Number(result.total ?? normalized.length); - if (taskType === 'unanalyzed') { - setUnanalyzedTotal(total); - setUnanalyzedList(normalized); - } else if (taskType === 'running') { - setRunningTotal(total); - setRunningList(normalized); - } else { - setCompletedTotal(total); - setCompletedList(normalized); + if (normalized.length === 0) { + // 接口无数据时回落到本地预览数据,方便联调看效果 + const mock = sentimentAnalyzeMock[taskType]; + applyColumnData(taskType, mock, mock.length); + return; } + + applyColumnData(taskType, normalized, Number(result.total ?? normalized.length)); + } catch (err) { + console.warn('三情任务查询失败,使用本地预览数据', err); + const mock = sentimentAnalyzeMock[taskType]; + applyColumnData(taskType, mock, mock.length); } finally { if (taskType === 'unanalyzed') setUnanalyzedLoading(false); if (taskType === 'running') setRunningLoading(false); @@ -153,23 +166,19 @@ const SentimentAnalyzes: React.FC = ({ title, category, subCa const handleOpenConsultation = () => { const task = stateRef.current.task; if (!task) return; - - try { - const sceneItem = JSON.parse(task.scene || '{}'); - if (sceneItem.taskJson) { - const taskJson = JSON.parse(sceneItem.taskJson); - setStartConfig({ - ...taskJson, - llm_preset_id: 'BAILING', - consistency_mode: 'soft', - phase_config: { diverge_rounds: 3 }, - task_context: task.overview, - }); - navigate('/qa-center/mope-chat'); - } - } finally { - setShowAnalysisModal(false); - } + // 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(3Q=三情分析)。 + // 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 3Q 配置的业务链(跳过选链弹窗)。 + navigate('/page/strategy/qa/multi-agent-roundtable', { + state: { + roundtablePrefill: { + taskTitle: task.taskName, + taskGoal: task.overview, + businessCode: '3Q', + autoStart: true, + }, + }, + }); + setShowAnalysisModal(false); }; const handleKeyDown = async (event: React.KeyboardEvent) => { diff --git a/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx index c4297e29..36eb93a8 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx @@ -4,10 +4,10 @@ import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from ' import dayjs from 'dayjs'; import { useNavigate } from 'react-router-dom'; import { useTheme } from '@/contexts/ThemeContext'; -import { useChatContext } from '@/contexts/ChatContext'; import { requestCrudService } from '@/service/request-axios-service'; import { KanbanColumn } from './KanbanColumn'; import { TaskListItem } from './TaskManagement'; +import { taskAnalyzeMock } from './kanbanMock'; interface TaskListPageProps { title: string; @@ -28,7 +28,6 @@ const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; const TaskAnalyzes: React.FC = ({ title, category, subCategory }) => { const { theme } = useTheme(); - const { setStartConfig } = useChatContext(); const navigate = useNavigate(); const isDark = theme === 'dark'; @@ -80,6 +79,19 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor await taskDataChange(); }; + const applyColumnData = (taskType: ColumnType, list: TaskListItem[], total: number) => { + if (taskType === 'unanalyzed') { + setUnanalyzedTotal(total); + setUnanalyzedList(list); + } else if (taskType === 'running') { + setRunningTotal(total); + setRunningList(list); + } else { + setCompletedTotal(total); + setCompletedList(list); + } + }; + const taskListSearch = async ( taskType: ColumnType, pageNum = 1, @@ -107,18 +119,19 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor column: taskType, taskLevel: item.taskLevel || 1, })); - const total = Number(result.total ?? normalized.length); - if (taskType === 'unanalyzed') { - setUnanalyzedTotal(total); - setUnanalyzedList(normalized); - } else if (taskType === 'running') { - setRunningTotal(total); - setRunningList(normalized); - } else { - setCompletedTotal(total); - setCompletedList(normalized); + if (normalized.length === 0) { + // 接口无数据时回落到本地预览数据,方便联调看效果 + const mock = taskAnalyzeMock[taskType]; + applyColumnData(taskType, mock, mock.length); + return; } + + applyColumnData(taskType, normalized, Number(result.total ?? normalized.length)); + } catch (err) { + console.warn('任务查询失败,使用本地预览数据', err); + const mock = taskAnalyzeMock[taskType]; + applyColumnData(taskType, mock, mock.length); } finally { if (taskType === 'unanalyzed') setUnanalyzedLoading(false); if (taskType === 'running') setRunningLoading(false); @@ -153,23 +166,19 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor const handleOpenConsultation = () => { const task = stateRef.current.task; if (!task) return; - - try { - const sceneItem = JSON.parse(task.scene || '{}'); - if (sceneItem.taskJson) { - const taskJson = JSON.parse(sceneItem.taskJson); - setStartConfig({ - ...taskJson, - llm_preset_id: 'BAILING', - consistency_mode: 'soft', - phase_config: { diverge_rounds: 3 }, - task_context: task.overview, - }); - navigate('/qa-center/mope-chat'); - } - } finally { - setShowAnalysisModal(false); - } + // 会商自动化串联(模块 C):跳转圆桌规划页,带上任务名称/描述 + 本页业务码(6BF=任务FX)。 + // 圆桌据此自动开跑 Step 1,并在「进入研讨」时自动选用 6BF 配置的业务链(跳过选链弹窗)。 + navigate('/page/strategy/qa/multi-agent-roundtable', { + state: { + roundtablePrefill: { + taskTitle: task.taskName, + taskGoal: task.overview, + businessCode: '6BF', + autoStart: true, + }, + }, + }); + setShowAnalysisModal(false); }; const handleKeyDown = async (event: React.KeyboardEvent) => { @@ -236,9 +245,6 @@ const TaskAnalyzes: React.FC = ({ title, category, subCategor
-
- -

{title} diff --git a/frontend-web/src/strategy-components/components/task-components/actionMock.ts b/frontend-web/src/strategy-components/components/task-components/actionMock.ts new file mode 100644 index 00000000..a75f4055 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/actionMock.ts @@ -0,0 +1,130 @@ +/** + * 行动 CE 看板的本地预览数据。 + * + * 仅在接口不可用 / 返回为空时回落使用(见 ActionAnalyzes 的 catch 分支), + * 后端正常返回数据时不会用到。纯前端联调 / 演示效果用。 + */ +import { + actionColumnItem, + ActionListItem, + TaskListItem, + taskStatusList, +} from "./TaskManagement"; + +const statusMap = taskStatusList as Record; + +/** 左侧任务列表 —— 覆盖「待分析 / 分析中 / 审核通过(完成) / 审核未通过」四种状态图标。 */ +export const mockTaskList: TaskListItem[] = ( + [ + { + id: 9001, + taskName: "京津冀区域舆情联动处置专项", + taskStartDate: "2026-06-08 09:12:00", + taskStatus: 20, + endCount: 3, + sumAll: 8, + }, + { + id: 9002, + taskName: "重点人群引导策略分析", + taskStartDate: "2026-06-07 14:30:00", + taskStatus: 10, + endCount: 0, + sumAll: 5, + }, + { + id: 9003, + taskName: "阶段性成效复盘与归档", + taskStartDate: "2026-06-05 16:45:00", + taskStatus: 110, + endCount: 6, + sumAll: 6, + }, + { + id: 9004, + taskName: "审核未通过任务回溯", + taskStartDate: "2026-06-04 11:10:00", + taskStatus: 50, + endCount: 2, + sumAll: 7, + }, + ] as Array> +).map((t) => ({ + ...t, + progress: t.sumAll ? (t.endCount as number) / (t.sumAll as number) : 0, + taskStatusData: statusMap[String(t.taskStatus)], +})) as unknown as TaskListItem[]; + +function action(partial: Partial): ActionListItem { + return partial as unknown as ActionListItem; +} + +/** 右侧四栏行动 —— 列与 ActionAnalyzes 里真实构建的四栏一一对应。 */ +export const mockActionColumns: actionColumnItem[] = [ + { + columnName: "待分析", + icon: "待CEH.svg", + actionList: [ + action({ + id: 1101, + actionName: "梳理重点平台舆情数据,输出待分析清单", + level: 1, + deptName: "情报研判处", + actionStatus: 0, + }), + action({ + id: 1102, + actionName: "采集竞品动向与受众反馈样本", + level: 2, + deptName: "数据采集组", + actionStatus: 0, + }), + ], + }, + { + columnName: "CEH中", + icon: "进行中.svg", + actionList: [ + action({ + id: 1201, + actionName: "开展多智能体会商,形成初步处置共识", + level: 1, + deptName: "策略策划处", + actionStatus: 1, + }), + ], + }, + { + columnName: "CEH完成", + icon: "完成.svg", + actionList: [ + action({ + id: 1301, + actionName: "完成作战方案撰写并通过审核", + level: 1, + deptName: "指挥协调中心", + actionStatus: 1, + }), + action({ + id: 1302, + actionName: "归档阶段性成效报告并同步相关单位", + level: 2, + deptName: "综合保障组", + actionStatus: 1, + }), + ], + }, + { + columnName: "已终止", + icon: "已终止.svg", + actionList: [ + action({ + id: 1401, + actionName: "因优先级调整终止的预备行动", + level: 2, + deptName: "综合保障组", + actionStatus: 1, + }), + ], + }, +]; diff --git a/frontend-web/src/strategy-components/components/task-components/icons/index.ts b/frontend-web/src/strategy-components/components/task-components/icons/index.ts new file mode 100644 index 00000000..a1adb766 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/index.ts @@ -0,0 +1,30 @@ +/** + * 行动 / 任务状态图标。 + * + * 用 `import` 引入 SVG,让 Vite 把它们打进产物并生成带 hash 的最终 URL, + * 这样无论部署到子路径(base)还是带指纹的静态资源服务都能正确访问 —— + * 不能再用 `/icon/xxx.svg` 这种运行时绝对路径(部署后会 404)。 + * + * key 沿用后端 / taskStatusList 里给的中文文件名,方便按名取用。 + */ +import daiQianShou from "./待签收.svg"; +import daiCEH from "./待CEH.svg"; +import jinXingZhong from "./进行中.svg"; +import wanCheng from "./完成.svg"; +import weiTongGuo from "./未通过.svg"; +import yiZhongZhi from "./已终止.svg"; + +export const statusIconMap: Record = { + "待签收.svg": daiQianShou, + "待CEH.svg": daiCEH, + "进行中.svg": jinXingZhong, + "完成.svg": wanCheng, + "未通过.svg": weiTongGuo, + "已终止.svg": yiZhongZhi, +}; + +/** 按文件名取打包后的图标 URL;取不到返回 undefined( 不渲染 broken icon)。 */ +export function resolveStatusIcon(name?: string | null): string | undefined { + if (!name) return undefined; + return statusIconMap[name]; +} diff --git a/frontend-web/src/strategy-components/components/task-components/icons/完成.svg b/frontend-web/src/strategy-components/components/task-components/icons/完成.svg new file mode 100644 index 00000000..a05e3a40 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/完成.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/已终止.svg b/frontend-web/src/strategy-components/components/task-components/icons/已终止.svg new file mode 100644 index 00000000..6324baa5 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/已终止.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/待CEH.svg b/frontend-web/src/strategy-components/components/task-components/icons/待CEH.svg new file mode 100644 index 00000000..d832a6cc --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/待CEH.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/待签收.svg b/frontend-web/src/strategy-components/components/task-components/icons/待签收.svg new file mode 100644 index 00000000..ce2b1dc6 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/待签收.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/未通过.svg b/frontend-web/src/strategy-components/components/task-components/icons/未通过.svg new file mode 100644 index 00000000..0310b3a2 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/未通过.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/icons/进行中.svg b/frontend-web/src/strategy-components/components/task-components/icons/进行中.svg new file mode 100644 index 00000000..fdecefde --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/icons/进行中.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend-web/src/strategy-components/components/task-components/kanbanMock.ts b/frontend-web/src/strategy-components/components/task-components/kanbanMock.ts new file mode 100644 index 00000000..2dec3776 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/kanbanMock.ts @@ -0,0 +1,148 @@ +/** + * 看板(任务分析 / 三情分析)的本地预览数据。 + * + * 仅在接口不可用 / 返回为空时回落使用(见各页 taskListSearch 的空数据 / catch 分支), + * 后端正常返回数据时不会用到。纯前端联调 / 演示效果用。 + */ +import { TaskListItem } from "./TaskManagement"; + +export type KanbanColumnType = "unanalyzed" | "running" | "completed"; + +interface Seed { + taskName: string; + overview: string; + taskDirection: string; + taskType?: number; // 1单任务 2场景 3会商 4多智能体 5检索 + taskLevel?: number; // 1一般 2紧急 3加急 + createTime?: string; + updateTime?: string; +} + +function buildTask(seed: Seed, column: KanbanColumnType, id: number): TaskListItem { + return { + id, + column, + taskName: seed.taskName, + overview: seed.overview, + taskDirection: seed.taskDirection, + taskType: seed.taskType ?? 1, + taskLevel: seed.taskLevel ?? 1, + createTime: seed.createTime ?? "2026-06-08 09:30:00", + updateTime: seed.updateTime ?? "2026-06-09 10:15:00", + scene: "", + } as unknown as TaskListItem; +} + +function makeMock( + baseId: number, + seeds: Record, +): Record { + let id = baseId; + const build = (col: KanbanColumnType) => seeds[col].map((s) => buildTask(s, col, (id += 1))); + return { + unanalyzed: build("unanalyzed"), + running: build("running"), + completed: build("completed"), + }; +} + +/** 任务分析(plan-task-fx)预览数据。 */ +export const taskAnalyzeMock = makeMock(7100, { + unanalyzed: [ + { + taskName: "区域目标人群拆解专项", + overview: "对重点区域目标人群进行分层拆解,明确触达优先级与切入口。", + taskDirection: "策略策划处", + taskType: 4, + taskLevel: 2, + createTime: "2026-06-09 08:20:00", + updateTime: "2026-06-09 08:20:00", + }, + { + taskName: "竞品动向监测立项", + overview: "梳理同类项目近期动向,输出可对照的策略阻力清单。", + taskDirection: "情报研判处", + taskType: 5, + taskLevel: 1, + createTime: "2026-06-08 16:45:00", + updateTime: "2026-06-08 17:02:00", + }, + ], + running: [ + { + taskName: "阶段目标可行性会商", + overview: "多智能体围绕阶段目标的可达性与风险点开展讨论并形成初步结论。", + taskDirection: "指挥协调中心", + taskType: 3, + taskLevel: 3, + createTime: "2026-06-07 10:10:00", + updateTime: "2026-06-09 09:40:00", + }, + ], + completed: [ + { + taskName: "重点议题策略路径分析", + overview: "完成重点议题的策略路径推演,已形成可执行的行动建议。", + taskDirection: "策略策划处", + taskType: 2, + taskLevel: 1, + createTime: "2026-06-04 14:00:00", + updateTime: "2026-06-06 11:25:00", + }, + { + taskName: "受众态度归因复盘", + overview: "对上一阶段受众态度变化进行归因复盘,沉淀经验供后续复用。", + taskDirection: "综合保障组", + taskType: 1, + taskLevel: 2, + createTime: "2026-06-03 09:15:00", + updateTime: "2026-06-05 18:30:00", + }, + ], +}); + +/** 三情分析(plan-task-sentiment)预览数据。 */ +export const sentimentAnalyzeMock = makeMock(7200, { + unanalyzed: [ + { + taskName: "重点平台情绪监测专项", + overview: "采集重点平台舆论样本,识别情绪倾向与潜在风险信号。", + taskDirection: "情报研判处", + taskType: 5, + taskLevel: 2, + createTime: "2026-06-09 07:50:00", + updateTime: "2026-06-09 07:50:00", + }, + { + taskName: "舆情走势预研立项", + overview: "围绕热点事件开展舆情走势预研,输出三情研判初判结论。", + taskDirection: "策略策划处", + taskType: 1, + taskLevel: 1, + createTime: "2026-06-08 13:20:00", + updateTime: "2026-06-08 13:48:00", + }, + ], + running: [ + { + taskName: "群体情绪共识会商", + overview: "多智能体从社情、舆情、敌情多视角讨论群体情绪态势并形成共识。", + taskDirection: "指挥协调中心", + taskType: 3, + taskLevel: 3, + createTime: "2026-06-07 11:30:00", + updateTime: "2026-06-09 10:05:00", + }, + ], + completed: [ + { + taskName: "阶段性情绪研判归档", + overview: "完成阶段性三情研判,结论已通过审核并归档同步相关单位。", + taskDirection: "综合保障组", + taskType: 2, + taskLevel: 1, + createTime: "2026-06-02 15:10:00", + updateTime: "2026-06-05 16:40:00", + }, + ], +}); diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 06797fce..5eb1accc 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -30,6 +30,7 @@ from app.gateway.routers import ( artifacts, assistants_compat, auth, + business_mapping, channels, checkpoint_migration, feedback, @@ -766,6 +767,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: "Roundtable functional agent seeding failed at startup (non-fatal)" ) + # 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。 + try: + business_mapping_store = getattr(app.state, "business_mapping_store", None) + if business_mapping_store is not None: + inserted = await business_mapping_store.ensure_seed_defaults() + if inserted: + logger.info("Seeded %d business mapping rows", inserted) + except Exception: + logger.exception("Business mapping seeding failed at startup (non-fatal)") + # 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。 # 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB # SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低 @@ -1124,6 +1135,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Roundtable-planning business chains at /api/roundtable-chains app.include_router(roundtable_chains.router) + # Global business → agent / chain mapping at /api/business-mapping + app.include_router(business_mapping.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 22973c3b..cfc86437 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -179,6 +179,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.roundtable_chain_store = make_roundtable_chain_store(sf) + from deerflow.persistence.business_mapping import make_business_mapping_store + + app.state.business_mapping_store = make_business_mapping_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/business_mapping.py b/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py new file mode 100644 index 00000000..8ff56055 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/business_mapping.py @@ -0,0 +1,87 @@ +"""CRUD API for the global business → agent / chain mapping (业务链条管理). + +Binds a fixed business code (3Q / 6BF / 7BF / 8BF) to an optional 智能体 and an +optional 业务链条 (a **public** ``roundtable_chains`` row). Used to auto-pick the +business chain when a task page launches the multi-agent roundtable. + +Global / shared — there is no user scoping; all accounts see and edit the same +rows. The four business rows are seeded on startup; this API only lists them and +updates the agent / chain columns (no create/delete). + +Routes (prefix ``/api/business-mapping``): + GET "" list all business mappings (display order) + PUT "/{business_code}" update one row's agent / chain selection +""" + +from __future__ import annotations + +import logging +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/business-mapping", tags=["business-mapping"]) + + +# ── schemas ──────────────────────────────────────────────────────────────── + + +class BusinessMappingResponse(BaseModel): + business_code: str + label: str = "" + agent_id: str | None = None + agent_name: str | None = None + chain_id: str | None = None + chain_title: str | None = None + sort_order: int = 0 + created_at: datetime | str | None = None + updated_at: datetime | str | None = None + + +class BusinessMappingListResponse(BaseModel): + mappings: list[BusinessMappingResponse] + + +class BusinessMappingUpdateRequest(BaseModel): + # All optional → callers send only what changed; explicit null clears a field. + agent_id: str | None = Field(default=None, max_length=64) + agent_name: str | None = Field(default=None, max_length=191) + chain_id: str | None = Field(default=None, max_length=64) + chain_title: str | None = Field(default=None, max_length=512) + + +# ── helpers ────────────────────────────────────────────────────────────── + + +def _get_store(request: Request): + store = getattr(request.app.state, "business_mapping_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Business mapping store not available") + return store + + +# ── routes ─────────────────────────────────────────────────────────────── + + +@router.get("", response_model=BusinessMappingListResponse) +async def list_mappings(request: Request) -> BusinessMappingListResponse: + store = _get_store(request) + rows = await store.list_mappings() + return BusinessMappingListResponse( + mappings=[BusinessMappingResponse(**r) for r in rows] + ) + + +@router.put("/{business_code}", response_model=BusinessMappingResponse) +async def update_mapping( + request: Request, business_code: str, body: BusinessMappingUpdateRequest +) -> BusinessMappingResponse: + store = _get_store(request) + # Only forward fields the caller explicitly sent (distinguish omitted vs null). + fields = body.model_dump(exclude_unset=True) + row = await store.update_mapping(business_code, **fields) + if row is None: + raise HTTPException(status_code=404, detail="Business mapping not found") + return BusinessMappingResponse(**row) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 152ebfb2..d95031b0 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -14,7 +14,7 @@ models: display_name: DeepSeek Chat use: langchain_openai:ChatOpenAI model: deepseek-chat - api_key: sk-e1c22b7931dc446f8b67e57878edd105 + api_key: sk-3fee562f834a4c08bb0d471ef44cb445 base_url: https://api.deepseek.com request_timeout: 600.0 max_retries: 2 @@ -27,7 +27,7 @@ models: display_name: DeepSeek V4 Flash use: langchain_openai:ChatOpenAI model: deepseek-v4-flash - api_key: sk-e1c22b7931dc446f8b67e57878edd105 + api_key: sk-3fee562f834a4c08bb0d471ef44cb445 base_url: https://api.deepseek.com request_timeout: 600.0 max_retries: 2 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/__init__.py new file mode 100644 index 00000000..664c05ee --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/__init__.py @@ -0,0 +1,18 @@ +"""Global business → agent / chain mapping persistence (业务链条管理).""" + +from __future__ import annotations + +from deerflow.persistence.business_mapping.model import BusinessMappingRow +from deerflow.persistence.business_mapping.sql import BusinessMappingRepository + +__all__ = [ + "BusinessMappingRepository", + "BusinessMappingRow", + "make_business_mapping_store", +] + + +def make_business_mapping_store(session_factory): + if session_factory is None: + return None + return BusinessMappingRepository(session_factory) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/model.py new file mode 100644 index 00000000..b8bad5c6 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/model.py @@ -0,0 +1,53 @@ +"""ORM model for the global business → agent / chain mapping (业务链条管理). + +A "business mapping" row binds a fixed business code (3Q / 6BF / 7BF / 8BF) to an +optional 智能体 (``agent_id``) and an optional 业务链条 (``chain_id`` → a +``roundtable_chains`` row, which must be **public** so every account can resolve +it). Used to auto-pick the business chain when a task page launches the +multi-agent roundtable. + +Unlike ``roundtable_chains`` (per-user), this mapping is **global / shared** — +there is no ``user_id`` column; all accounts see and use the same configuration. +The fixed business rows are seeded on startup; users only fill in the +agent / chain columns from the management page. + +``agent_name`` / ``chain_title`` are denormalized display snapshots so the table +renders without cross-referencing and survives a later agent/chain rename or +delete (the picked id still drives the auto-flow). +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base +from deerflow.persistence.types import BeijingDateTime + + +class BusinessMappingRow(Base): + __tablename__ = "business_mapping" + + # Business code is the natural primary key (e.g. "3Q", "6BF"). Global, no user scope. + business_code: Mapped[str] = mapped_column(String(32), primary_key=True) + # Display label shown in the 业务 column (e.g. "任务FX"). + label: Mapped[str] = mapped_column(String(128), nullable=False, default="") + # Picked 智能体 (暂存不用,自动流程当前不消费此字段). NULL = 未配置. + agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + agent_name: Mapped[str | None] = mapped_column(String(191), nullable=True) + # Picked 业务链条 → roundtable_chains.id (须为 public). NULL = 未配置. + chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + chain_title: Mapped[str | None] = mapped_column(String(512), nullable=True) + # Fixed display order for the management table. + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + BeijingDateTime(), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/sql.py new file mode 100644 index 00000000..ff14bb4d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/business_mapping/sql.py @@ -0,0 +1,120 @@ +"""SQLAlchemy-backed global business → agent / chain mapping store. + +Global / shared (no ``user_id`` scoping): every account reads and writes the same +rows. The four business rows are seeded once on startup (``ensure_seed_defaults``) +with empty agent/chain; users fill those in from the management page via +``update_mapping``. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.business_mapping.model import BusinessMappingRow + +_UNSET = object() + +# Fixed business rows (业务码 → 展示名). Seeded on first boot; agent/chain left empty. +# 8BF 页面尚未建好,先占位保留行。顺序即管理表的展示顺序。 +DEFAULT_BUSINESS_ROWS: list[dict[str, Any]] = [ + {"business_code": "3Q", "label": "3QFX"}, + {"business_code": "6BF", "label": "任务FX"}, + {"business_code": "7BF", "label": "XDFX"}, + {"business_code": "8BF", "label": "8BF"}, +] + + +class BusinessMappingRepository: + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + @staticmethod + def _to_dict(row: BusinessMappingRow) -> dict[str, Any]: + return { + "business_code": row.business_code, + "label": row.label, + "agent_id": row.agent_id, + "agent_name": row.agent_name, + "chain_id": row.chain_id, + "chain_title": row.chain_title, + "sort_order": row.sort_order, + "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at, + "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at, + } + + async def list_mappings(self) -> list[dict[str, Any]]: + stmt = select(BusinessMappingRow).order_by( + BusinessMappingRow.sort_order.asc(), BusinessMappingRow.business_code.asc() + ) + async with self._sf() as session: + result = await session.execute(stmt) + return [self._to_dict(row) for row in result.scalars()] + + async def get_mapping(self, business_code: str) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(BusinessMappingRow, business_code) + return self._to_dict(row) if row is not None else None + + async def update_mapping( + self, + business_code: str, + *, + agent_id=_UNSET, + agent_name=_UNSET, + chain_id=_UNSET, + chain_title=_UNSET, + ) -> dict[str, Any] | None: + """Update an existing (seeded) business row. Returns None if the code is unknown. + + Only forwarded fields are touched; explicit ``None`` clears that field. + """ + async with self._sf() as session: + row = await session.get(BusinessMappingRow, business_code) + if row is None: + return None + if agent_id is not _UNSET: + row.agent_id = agent_id or None + if agent_name is not _UNSET: + row.agent_name = agent_name or None + if chain_id is not _UNSET: + row.chain_id = chain_id or None + if chain_title is not _UNSET: + row.chain_title = chain_title or None + row.updated_at = datetime.now(UTC) + await session.commit() + await session.refresh(row) + return self._to_dict(row) + + async def ensure_seed_defaults(self) -> int: + """Idempotently insert the fixed business rows if missing. Returns inserted count. + + Never overwrites an existing row (respects user-configured agent/chain). + """ + inserted = 0 + now = datetime.now(UTC) + async with self._sf() as session: + for idx, seed in enumerate(DEFAULT_BUSINESS_ROWS): + existing = await session.get(BusinessMappingRow, seed["business_code"]) + if existing is not None: + continue + session.add( + BusinessMappingRow( + business_code=seed["business_code"], + label=seed.get("label", ""), + agent_id=None, + agent_name=None, + chain_id=None, + chain_title=None, + sort_order=idx, + created_at=now, + updated_at=now, + ) + ) + inserted += 1 + if inserted: + await session.commit() + return inserted 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 7cb7408b..26e5cfef 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 @@ -22,6 +22,7 @@ from deerflow.persistence.agents.model import ( AgentSkillRow, ) from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow +from deerflow.persistence.business_mapping.model import BusinessMappingRow from deerflow.persistence.memory_config.model import UserMemoryConfigRow from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.llm_metrics.model import LlmCallMetricRow @@ -61,6 +62,7 @@ __all__ = [ "AgentRow", "AgentSkillRow", "AdminLeaderboardDailyStatRow", + "BusinessMappingRow", "LlmCallMetricRow", "NotificationRow", "ParallelAgentTaskRow",