From 68f6fc5c1615d88e8bfc9263fc6965eb8d13cb77 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 16 Jul 2026 15:46:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=89=8D=E7=AB=AF=20UI=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=8C=96=E9=87=8D=E6=9E=84=20+=20=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【新组件】 - ErrorBoundary: 全局错误边界,渲染崩溃时显示重试/刷新按钮 - ui/Toast: 全局 Toast 通知系统 (success/error/warning/info),Context Provider 模式 - ui/Modal: 统一弹窗 (ESC关闭 + loading遮罩 + 自定义footer) - ui/Skeleton: 骨架屏 (SkeletonTable / SkeletonCard) - ui/Lightbox: 图片放大灯箱 【页面迁移】 - InquiryPage: useToast + Modal + SkeletonTable 替换手写代码,新增 pubForm 发布前预填联系人/电话/地址 + 发布时传 body 给后端 - LeadDetailPage: 迁移到 Toast + Modal - ProductPage: 迁移到 Toast + Modal + SkeletonTable - AdminPage: 迁移到 useToast - ComparisonPage: 迁移到 Toast + SkeletonTable - ImagePage: 迁移到 Toast + SkeletonCard 【基础设施】 - globals.css: 新增 fadeIn/slideUp 动画 - page.tsx: SPA 根接入 ToastProvider + ErrorBoundary - tailwind.config.ts: 新增 animate keyframes 【后端】 - integration.service.ts: MCP session 管理 + charset 修复 - lead.controller.ts/lead.service.ts: 发布端点增强 + 物料提取服务注入 --- AGENTS.md | 178 ++++++- .../src/integration/integration.service.ts | 73 ++- apps/api/src/lead/lead.controller.ts | 4 +- apps/api/src/lead/lead.service.ts | 26 +- apps/web/src/app/globals.css | 21 + apps/web/src/app/page.tsx | 36 +- apps/web/src/components/AdminPage.tsx | 13 +- apps/web/src/components/ComparisonPage.tsx | 17 +- apps/web/src/components/ErrorBoundary.tsx | 79 +++ apps/web/src/components/ImagePage.tsx | 42 +- apps/web/src/components/InquiryPage.tsx | 475 ++++++++++-------- apps/web/src/components/LeadDetailPage.tsx | 127 +++-- apps/web/src/components/OverviewPage.tsx | 15 +- apps/web/src/components/ProductPage.tsx | 208 ++++---- apps/web/src/components/Sidebar.tsx | 283 ++++++----- apps/web/src/components/ui/Lightbox.tsx | 34 ++ apps/web/src/components/ui/Modal.tsx | 79 +++ apps/web/src/components/ui/Skeleton.tsx | 68 +++ apps/web/src/components/ui/Toast.tsx | 74 +++ apps/web/tailwind.config.ts | 2 + 20 files changed, 1228 insertions(+), 626 deletions(-) create mode 100644 apps/web/src/components/ErrorBoundary.tsx create mode 100644 apps/web/src/components/ui/Lightbox.tsx create mode 100644 apps/web/src/components/ui/Modal.tsx create mode 100644 apps/web/src/components/ui/Skeleton.tsx create mode 100644 apps/web/src/components/ui/Toast.tsx diff --git a/AGENTS.md b/AGENTS.md index 8530d1a..330451e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,10 +85,13 @@ npm run dev:stop # kill PID 文件中记录的进程 |---|---|---| | **3001** | 平台助手 web (Next.js) | `npm run dev:web` 或 `npm run dev` | | **4000** | 平台助手 API (NestJS) | `npm run dev:api` 或 `npm run dev` | -| **3002** | devbox MCP server (Node.js) | `ssh devbox` + PM2, **不在 Mac 本地** | +| **3002** | devbox MCP server (Node.js 版 `product_network_mcp`) | `ssh devbox` + PM2, **不在 Mac 本地** | +| **8095** | devbox MCP server (Java 版 `industry-mcp-server` Docker) | `ssh devbox` + `docker ps` 看 `industry-mcp-server`, **不在 Mac 本地** | -注意:**3002 是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。 -打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002)。 +注意:**3002 / 8095 都是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。 +打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002/8095)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用。 + +> ⚠️ 2026-07-15 实测: 平台助手本地 `next dev` 偶尔会 fallback 到 3000 端口(被 `admin_new` Vite 项目抢 3001 时),以实际启动后终端打印的端口为准。`AdminPage.tsx:1000` 默认填的 endpoint 仍是 `http://100.111.84.16:3002/mcp`(Node 版)。 AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP server 地址 (填到 ConfigTab 的"endpoint" 字段), 不是 web 入口。 @@ -122,7 +125,7 @@ AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP serv | ImagePage | ✅ GET `api/images`,POST generate/parse(MiniMax 生图) | | ProductPage | ✅ GET/POST/PATCH/DELETE `api/products` + POST upload-image | | OverviewPage | ✅ Promise.all([GET `api/leads/stats`, GET `api/leads`]) | -| InquiryPage | ✅ GET `api/leads`,POST `api/leads/{id}/publish` | +| InquiryPage | ✅ GET `api/leads`,POST `api/leads/{id}/publish`(含发布前确认弹窗) | | LeadDetailPage | ✅ GET `api/leads/{id}`,POST to-rfq/to-collab/publish/follow-up | | RuleCenterPage | 含"采集器配置"Tab:GET/POST/PUT `api/crawlers`,POST run/pause/resume | | ReportCenterPage | 纯 mock | @@ -172,8 +175,8 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor - endpoint:`https://api.minimaxi.com/v1/chat/completions` - model:`MiniMax-M2.7`(env `MINIMAX_MODEL` 可覆盖) -- env:`MINIMAX_API_KEY` 写入 `apps/api/.env`(生产已配) -- **注意**:M2.7 是推理模型,max_tokens 要 ≥500 才够(否则全被 吃掉) +- env:`MINIMAX_API_KEY` 写入 `apps/api/.env`(生产已配)。本地开发 `.env` 也需要配,否则物料提取/category-inference 走兜底 +- **注意**:M2.7 是推理模型,max_tokens 要 ≥500 才够(否则全被 `` 吃掉)。物料提取正文用 2000,标题提取用 800 - 生产 `.env` 同时保留 `MINIMAX_API_KEY` 和 `MINIMAX_MODEL` 两个变量 ### 历史回填策略(重要:AI 不可全信) @@ -233,6 +236,7 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor 2. `rsync` 同步 dist/.next + 源码到 `/opt/pingtai/` 3. `pm2 restart pingtai-api pingtai-web`(注意 `pm2 restart` 默认不更新环境变量,要 `--update-env`) 4. Prisma 变更后必须先 `npx prisma migrate deploy && npx prisma generate`(在 `apps/api/` 下) +- ⚠️ **Web 构建注意**:本地 build 的 `.next/` 若直接 rsync 到生产,可能因 `node_modules` 版本不一致导致 chunk 加载失败。遇此情况,在生产服务器上执行 `cd /opt/pingtai/apps/web && npx next build` 重建。 - **常见操作**: - `pm2 list` / `pm2 logs` / `pm2 restart pingtai-api` - `tail -f /var/log/pingtai/api.out-1.log` @@ -252,6 +256,7 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor | `CrawlerService.run()` | method | apps/api/src/crawler/crawler.service.ts | 采集器入口,execSync 调度脚本 | | `ImageService` | class | apps/api/src/image/image.service.ts | 三阶段生图(图库→Bing→AI) | | `runGenericScript()` | method | apps/api/src/crawler/crawler.service.ts | 通用化采集器调度 | +| `MaterialExtractorService` | class | apps/api/src/lead/material-extractor.service.ts | **W37 新增** 发布时实时提取物料清单 (items),三级降级链 | | `search_image.py` | script | scripts/search_image.py | Bing 搜索 top N 候选 | | `process_image.py` | script | scripts/process_image.py | 去水印+裁剪+白底合成 | @@ -262,21 +267,34 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor ``` 平台助手 NestJS (4000) ↓ IntegrationService.callTool() - ↓ MCP 协议 (Streamable HTTP) + ↓ MCP 协议 (Streamable HTTP, POST 方法) ↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key) -renren-mcp-server (Node.js, devbox:3002) +renren-mcp-server + ├─ Node.js 版 (devbox:3002, product_network_mcp) + └─ Java 版 (devbox:8095, industry-mcp-server Docker 容器) ↓ 直调 Java 业务方法 ↓ product_network 7 个 Java 服务 ``` -注: MCP server 端口 3002 是 **devbox 开发机** 上的端口, 不是平台助手本地端口 (3001/4000)。 +注: MCP server 端口 **3002(Node)和 8095(Java Docker)都是 devbox 开发机上的端口**, 不是平台助手本地端口 (3001/4000)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用, 申请时确认走哪一套。 + +### MCP 端点要求(重要!) + +平台助手消费 MCP 时, 端点必须满足: + +- **协议**: Streamable HTTP (`@modelcontextprotocol/sdk` 的 `StreamableHTTPClientTransport`) +- **方法**: **POST** (不是 GET). 如果 MCP server 实现要求 GET, 需要改 server 而不是 client +- **认证头**: 多租户 APIKey → `x-app-id` + `x-app-secret`, 由 devbox B2B 运营后台"集成管理 → 应用列表"申请 +- **路径示例**: `http://host:port/mcp` (注意末尾 `/mcp`) + +前端 "MCP 对接配置 → 新增对接" 弹窗的 MCP 地址字段下方有提示文案提醒这些要求。 ### 设计原则 - **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4) - **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销) -- **APIKey 认证**:Header `x-api-key`,AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex) +- **APIKey 认证**:Header `x-app-id` + `x-app-secret`(多租户, 替代旧的 `x-api-key`), AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex) - **公司账号代理**:每客户 1 个 `sys_user` (user_type=99) ### 关键代码路径 @@ -284,11 +302,15 @@ product_network 7 个 Java 服务 | 文件 | 说明 | |------|------| | `apps/api/prisma/schema.prisma` | `Integration` 表(MCP 连接配置) | -| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool) | +| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool)+ **session 管理 + charset 修复** | | `apps/api/src/integration/integration.controller.ts` | REST API(CRUD + test + connect) | | `apps/api/src/integration/integration.module.ts` | NestJS 模块 | -| `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool | -| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面) | +| `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool + **可选 overrides 参数** | +| `apps/api/src/lead/lead.controller.ts` | `POST /api/leads/:id/publish` 新增可选 body | +| `apps/api/src/lead/material-extractor.service.ts` | **W37 新增** 物料清单提取(发布时实时调 MiniMax) | +| `apps/api/src/lead/lead.module.ts` | 注册 `MaterialExtractorService` | +| `apps/web/src/components/InquiryPage.tsx` | **发布前确认弹窗**(对接文档 §5.2) | +| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面)+ MCP 协议提示文案 | ### 使用流程 @@ -304,13 +326,50 @@ product_network 7 个 Java 服务 const client = new Client({ name: 'platform-assistant', version: '1.0.0' }) const transport = new StreamableHTTPClientTransport( new URL(endpoint), - { requestInit: { headers: { 'x-api-key': apiKey } } } + { + requestInit: { + headers: { 'x-app-id': appId, 'x-app-secret': appSecret }, + }, + // 自定义 fetch: 自动管理 mcp-session-id (MCP Streamable HTTP 协议要求) + fetch: this.buildSessionAwareFetch(id), + } ) await client.connect(transport) const tools = await client.listTools() await client.callTool({ name: 'publish_rfq', arguments: { title, ... } }) ``` +### MCP Session 管理(2026-07-15 实现) + +`integration.service.ts` 的 `buildSessionAwareFetch(integrationId)` 方法实现三层 session 管理: + +1. **提取**:自定义 fetch wrapper 从 initialize 响应头 `mcp-session-id` 提取并缓存到 `mcpSessionIds: Map` +2. **注入**:后续所有请求自动在 header 中带 `mcp-session-id` +3. **清理**:disconnect / 重连时自动清空旧 session + +双保险设计(SDK 1.27.0 内置 session 管理 + 自定义 wrapper fetch 兜底): + +| 路径 | 谁管理 session | +|---|---| +| connect() initialization | wrapper fetch 从响应头提取 | +| listTools() / callTool() | wrapper fetch 注入 `mcp-session-id` header | +| executeTool() 自动重连 | `clearSession(id)` 后再调用 `connect()` | +| disconnect() | 调用 `clearSession(id)` | +| 进程关闭 (onModuleDestroy) | `mcpSessionIds.clear()` | + +### Content-Type charset 修复(2026-07-15) + +SDK 默认设 `content-type: application/json` 但不带 `charset=utf-8`。Java Jackson 解析器缺 charset 时可能不以 UTF-8 解码,导致多字节中文的字节计数与 Content-Length 不匹配,报 JSON 解析错误。 + +**修复**:在 `buildSessionAwareFetch` wrapper 中检测 Content-Type,自动补上 `charset=utf-8`: + +```typescript +const ct = headers.get('content-type') +if (ct && !ct.includes('charset') && ct.includes('application/json')) { + headers.set('content-type', 'application/json; charset=utf-8') +} +``` + ### API 端点 | 方法 | 路径 | 说明 | @@ -333,6 +392,97 @@ await client.callTool({ name: 'publish_rfq', arguments: { title, ... } }) - 连接状态存储在数据库,服务重启后需重新连接 - 发布失败降级为本地模式(生成假 inquiryNo) +### publish_rfq 字段映射(对接文档 4.1+4.2 节) + +`lead.service.ts` → `publishToDirev(id, overrides?)` 组装 `publishArgs` 后调 `executeTool('publish_rfq', args)`。 +`overrides` 可选参数:`{ contactPhone?, contactName?, deliveryAddress? }`,由前端弹窗传入。 + +| publishArgs 字段 | lead 来源 / 默认值 | 文档要求 | 类型 | 备注 | +|---|---|---|---|---| +| title | `lead.title` | 询价标题 | string | 限 50 字,超长报错 | +| items | MaterialExtractor 实时提取 | 物料清单行 | array | 可选,最多 20 行 | +| deliveryAddress | `overrides?.deliveryAddress` → `lead.regionDetail` → `lead.region` | 收货地址 | string | 弹窗可编辑覆盖 | +| deliveryDate | `deadline + 30 天` | 期望交货日期 | string YYYY-MM-DD | — | +| validDays | `7` | 报价有效期 | integer | **W37 新增** | +| contactName | `overrides?.contactName \|\| lead.contact \|\| '待补充'` | 联系人 | string | 弹窗可编辑 | +| contactPhone | `overrides?.contactPhone \|\| lead.phone \|\| '待补充'` | 联系手机 | string | **必填,弹窗补全** | +| inquiryParty | 硬编码 `郑州巨变网络科技有限公司` | 询价主体 | string | 企业配置 | +| freightFlag | `1` | 运费承担 | integer | 1=含运费(曾传字符串"含运费"报错,已修复) | +| procurementType | `1` | 采购类型 | integer | 1=公开采购(曾传字符串"定量采购"报错,已修复) | +| quotationDemand | `1` | 报价要求 | integer | **W37 新增**,1=需要报价 | +| invoiceType | `2` | 开票要求 | integer | 2=增值税专票(曾传字符串报错,已修复) | +| paymentMethod | **不传** | 付款方式 | — | 后端用企业默认配置,传了"23"会报"企业字典找不到" | +| userId / enterpriseId | `Integration.companyUserId` | 企业身份 | string | MCP connect 时获取 | + +### 发布前确认弹窗(对接文档 §5.2,2026-07-16 实现) + +`InquiryPage.tsx` 中实现,用户勾选线索后点"一键发布"弹出: + +**弹窗布局**(三个区域): + +| 区域 | 数据来源 | 可编辑 | +|---|---|---| +| 询价信息 | 线索标题/品类/数量/单位 | ❌ 只读 | +| 收货信息 | 联系人、联系手机、收货地址 | ✅ 用户可编辑 | +| 业务默认值 | 企业硬编码字段值清单 | ❌ 只读 | + +**编辑字段**: + +| 字段 | 类型 | 验证 | 为空时 | +|---|---|---|---| +| contactName | 文本输入框 | 无 | 传 `'待补充'` | +| contactPhone | 电话输入框 | 标红提示"⚠ 必填" | 传 `'待补充'` | +| deliveryAddress | 文本输入框 | 标红提示"⚠ 必填" | 传线索地区数据 | + +**数据流**: + +``` +前端弹窗 → DO_Publish() → POST /api/leads/:id/publish { contactName, contactPhone, deliveryAddress } + → lead.service.ts publishToDirev(id, overrides) + → 覆盖组装 publishArgs + → executeTool('publish_rfq', publishArgs) + → MCP server + → 产业平台询价广场 +``` + +### publish 端点变化(2026-07-16) + +`lead.controller.ts`: + +| 端点 | 方法 | Body | 改动 | +|---|---|---|---| +| `POST /api/leads/:id/publish` | POST | `{ contactPhone?, contactName?, deliveryAddress? }` | 新增可选 body 参数 | +| `POST /api/leads/batch-publish` | POST | `{ ids: string[] }` | 未改(循环调 publish) | + +### 当前已知卡点(对接文档 §8 + 2026-07-16 实测) + +| 卡点 | 状态 | 说明 | +|---|---|---| +| `code:10021 "未获取到用户信息"` | ⏳ 产业平台修复中 | MCP server 用户上下文问题,创建 APIKey 时已关联 `companyUserId`,后端应直接使用 | +| `paymentMethod` 传 `"23"` 报"企业字典找不到" | ✅ 已规避 | 已修改:不传 paymentMethod,后端用默认值 | +| JSON 截断 / Content-Length 不匹配 | ✅ 已修复 | 加 `charset=utf-8` 后解决 | +| 字段值类型不匹配(字符串 vs 整数) | ✅ 已修复 | `freightFlag`, `procurementType`, `invoiceType` 改整数,`paymentMethod` 不传 | +| `tb_inquiry.title` varchar(50) 超长 | ✅ 已加前置校验 | 发布前 `publishToDirev()` 检查 title 长度,超 50 字提前报错 | + +### 数据质量现状(2026-07-16 生产数据统计) + +815 条线索中,以下字段**全军覆没**(采集器只抓了列表页元数据): + +| 字段 | 填写率 | 说明 | +|---|---|---| +| deadline | 702/815 (86%) | 有 | +| region | 696/815 (85%) | 有 | +| regionDetail | 542/815 (66%) | 有 | +| contact | 0/815 (0%) | **全缺** — 采集器未提取 | +| phone | 0/815 (0%) | **全缺** | +| quantity | 0/815 (0%) | **全缺** | +| unit | 0/815 (0%) | **全缺** | +| notes | 0/815 (0%) | **全缺** | + +**原因**:三个采集脚本(中电建/fetch-v1.2.js、新矿/fetch-xkgroup.py、中核/fetch-cnncecp.py)都只解析列表页,不访问详情页正文提取联系人/电话/数量/单位。这些字段需要新增"详情页内容提取"步骤,或依赖发布前弹窗让用户手动补全。 + +**当前应对**:发布前弹窗让用户手动填写 contactPhone 和 deliveryAddress。如需自动化提取,需在 crawler.service.ts 入库后增加类似 `notes-extractor` 的 AI 提取步骤(待实现)。 + ## ANTI-PATTERNS - 前端不加路由库,保持 SPA useState 模式 diff --git a/apps/api/src/integration/integration.service.ts b/apps/api/src/integration/integration.service.ts index 1c2ff59..053e6ad 100644 --- a/apps/api/src/integration/integration.service.ts +++ b/apps/api/src/integration/integration.service.ts @@ -14,6 +14,8 @@ interface MCPClientInstance { export class IntegrationService implements OnModuleDestroy { private readonly logger = new Logger(IntegrationService.name) private mcpClients: Map = new Map() + /** MCP session id 缓存 (per integration, MCP Streamable HTTP 协议要求) */ + private mcpSessionIds: Map = new Map() private readonly encryptKey: Buffer constructor(private prisma: PrismaService) { @@ -73,6 +75,58 @@ export class IntegrationService implements OnModuleDestroy { return headers } + /** + * MCP Streamable HTTP Session 管理 (per MCP spec 2025-03-26) + * + * 1. initialize 响应头里返回 mcp-session-id, 客户端必须提取并缓存 + * 2. 后续所有请求 (listTools/callTool/...) 必须带 mcp-session-id header + * 3. 用 POST 方法, 不是 GET + * + * SDK 1.27.0 默认实现了这部分, 但在 transport 重建或 401 错误路径上可能漏掉。 + * 这里加一层显式 wrapper fetch 做"双重保险": + * - 请求前: 自动注入缓存的 mcp-session-id + * - 响应后: 从响应头提取 mcp-session-id 并缓存 + */ + private buildSessionAwareFetch(integrationId: string): typeof fetch { + return async (input: any, init?: RequestInit): Promise => { + // 1. 注入已缓存的 session id (如果有) + const headers = new Headers(init?.headers || {}) + + // 确保 Content-Type 带 charset=utf-8 (SDK 默认只设 application/json, + // 不设 charset. Java Jackson 解析器缺 charset 时可能不以 UTF-8 解码, + // 导致多字节中文字节计数与 Content-Length 不匹配, 报 Unexpected end-of-input) + const ct = headers.get('content-type') + if (ct && !ct.includes('charset') && ct.includes('application/json')) { + headers.set('content-type', 'application/json; charset=utf-8') + } + + const cachedSessionId = this.mcpSessionIds.get(integrationId) + if (cachedSessionId && !headers.has('mcp-session-id')) { + headers.set('mcp-session-id', cachedSessionId) + } + + // 2. 发请求 + const response = await fetch(input, { ...init, headers }) + + // 3. 提取并缓存新的 session id (如果服务器返回了) + const newSessionId = response.headers.get('mcp-session-id') + if (newSessionId && newSessionId !== cachedSessionId) { + this.mcpSessionIds.set(integrationId, newSessionId) + this.logger.log(`[Session] ${integrationId} 获取新 session: ${newSessionId.slice(0, 8)}...`) + } + + return response + } + } + + /** 清空指定 integration 的 session (重连时用) */ + private clearSession(integrationId: string) { + if (this.mcpSessionIds.has(integrationId)) { + this.logger.log(`[Session] ${integrationId} 清空旧 session`) + this.mcpSessionIds.delete(integrationId) + } + } + async onModuleDestroy() { for (const [id, instance] of this.mcpClients) { try { @@ -82,6 +136,7 @@ export class IntegrationService implements OnModuleDestroy { } } this.mcpClients.clear() + this.mcpSessionIds.clear() } async create(data: { @@ -162,6 +217,8 @@ export class IntegrationService implements OnModuleDestroy { try { await this.disconnect(id) + // 重连前清空旧 session id, 强制重新走 initialize + this.clearSession(id) const client = new Client( { name: 'platform-assistant', version: '1.0.0' }, @@ -174,11 +231,19 @@ export class IntegrationService implements OnModuleDestroy { requestInit: { headers: this.buildAuthHeaders(integration), }, + // 自定义 fetch: 自动管理 mcp-session-id (MCP Streamable HTTP 协议要求) + fetch: this.buildSessionAwareFetch(id), } ) await client.connect(transport) + // 兜底: 如果 SDK 在内部已经提取过 session, 同步到我们自己的缓存 (双保险) + const sdkSessionId = (transport as any).sessionId + if (sdkSessionId && !this.mcpSessionIds.has(id)) { + this.mcpSessionIds.set(id, sdkSessionId) + } + const toolsResult = await client.listTools() const tools = toolsResult.tools || [] @@ -218,7 +283,9 @@ export class IntegrationService implements OnModuleDestroy { }, }) - this.logger.log(`MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}`) + this.logger.log( + `MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}, session=${this.mcpSessionIds.get(id)?.slice(0, 8) || 'none'}`, + ) return { success: true, tools } } catch (e: any) { @@ -241,6 +308,8 @@ export class IntegrationService implements OnModuleDestroy { } this.mcpClients.delete(id) } + // 断开时清空 session, 下次 connect 强制重新走 initialize + this.clearSession(id) } /** @@ -260,6 +329,7 @@ export class IntegrationService implements OnModuleDestroy { { capabilities: {} } ) + // 测试连接也走 session-aware fetch (用临时 integration id "test") transport = new StreamableHTTPClientTransport( new URL(endpoint), { @@ -269,6 +339,7 @@ export class IntegrationService implements OnModuleDestroy { 'x-app-secret': appSecret, }, }, + fetch: this.buildSessionAwareFetch('test'), } ) diff --git a/apps/api/src/lead/lead.controller.ts b/apps/api/src/lead/lead.controller.ts index 6753480..690248b 100644 --- a/apps/api/src/lead/lead.controller.ts +++ b/apps/api/src/lead/lead.controller.ts @@ -55,8 +55,8 @@ export class LeadController { } @Post(':id/publish') - publish(@Param('id') id: string) { - return this.leadService.publishToDirev(id) + publish(@Param('id') id: string, @Body() body?: { contactPhone?: string; contactName?: string; deliveryAddress?: string }) { + return this.leadService.publishToDirev(id, body) } @Post('batch-publish') diff --git a/apps/api/src/lead/lead.service.ts b/apps/api/src/lead/lead.service.ts index 909a3c9..15d3d2e 100644 --- a/apps/api/src/lead/lead.service.ts +++ b/apps/api/src/lead/lead.service.ts @@ -248,7 +248,10 @@ export class LeadService { }) } - async publishToDirev(id: string) { + async publishToDirev( + id: string, + overrides?: { contactPhone?: string; contactName?: string; deliveryAddress?: string }, + ) { const lead = await this.findOne(id) const now = new Date() @@ -290,17 +293,22 @@ export class LeadService { d.setDate(d.getDate() + 30) publishArgs.deliveryDate = d.toISOString().slice(0, 10) } - if (lead.regionDetail) publishArgs.shippingAddress = lead.regionDetail - else if (lead.region) publishArgs.shippingAddress = lead.region + if (overrides?.deliveryAddress) publishArgs.deliveryAddress = overrides.deliveryAddress + else if (lead.regionDetail) publishArgs.deliveryAddress = lead.regionDetail + else if (lead.region) publishArgs.deliveryAddress = lead.region - if (lead.contact) publishArgs.contactName = lead.contact - if (lead.phone) publishArgs.contactPhone = lead.phone + // 联系人信息(弹窗用户可编辑覆盖,否则取线索数据,最后用占位) + publishArgs.contactName = overrides?.contactName || lead.contact || '待补充' + publishArgs.contactPhone = overrides?.contactPhone || lead.phone || '待补充' + // 企业级业务字段(按 MCP publish_rfq 对接文档 4.2 节字段类型和值) publishArgs.inquiryParty = '郑州巨变网络科技有限公司' - publishArgs.freightFlag = '含运费' - publishArgs.procurementType = '定量采购' - publishArgs.invoiceType = '增值税专票' - publishArgs.paymentMethod = '货到付款' + publishArgs.freightFlag = 1 // 1=含运费(int,不要传字符串) + publishArgs.procurementType = 1 // 1=公开采购 + publishArgs.invoiceType = 2 // 2=增值税专票 + // paymentMethod 不传,后端会用企业默认配置(传了"23"反而会报"企业字典找不到") + publishArgs.quotationDemand = 1 // 1=需要报价(新增,文档要求) + publishArgs.validDays = 7 // 报价有效期天数(新增,文档要求) // Auto-fill enterprise identity from local Integration config if (b2bIntegration.companyUserId) { diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f15a261..1d5cd58 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -6,3 +6,24 @@ body { background: #f7f9fb; color: #191c1e; } + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b84f2d9..364f6d8 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -2,6 +2,8 @@ import { useState } from 'react' import { useAuth } from '@/components/AuthProvider' +import { ToastProvider } from '@/components/ui/Toast' +import ErrorBoundary from '@/components/ErrorBoundary' import Sidebar from '@/components/Sidebar' import LoginPage from '@/components/LoginPage' import OverviewPage from '@/components/OverviewPage' @@ -38,20 +40,24 @@ export default function Home() { if (!user) return return ( -
- -
- {page === 'overview' && setPage(p as Page)} />} - {page === 'inquiry' && } - {page === 'image' && } - {page === 'product' && } - {page === 'comparison' && } - {page === 'lead-detail' && setPage(p as Page)} />} - {page === 'report-center' && } - {page === 'rule-center' && } - {page === 'system-monitor' && } - {page.startsWith('admin-') && } -
-
+ +
+ +
+ + {page === 'overview' && setPage(p as Page)} />} + {page === 'inquiry' && } + {page === 'image' && } + {page === 'product' && } + {page === 'comparison' && } + {page === 'lead-detail' && setPage(p as Page)} />} + {page === 'report-center' && } + {page === 'rule-center' && } + {page === 'system-monitor' && } + {page.startsWith('admin-') && } + +
+
+
) } diff --git a/apps/web/src/components/AdminPage.tsx b/apps/web/src/components/AdminPage.tsx index dc67cdf..0a88b52 100644 --- a/apps/web/src/components/AdminPage.tsx +++ b/apps/web/src/components/AdminPage.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react' import { API_BASE } from '@/lib/apiBase' +import { useToast } from '@/components/ui/Toast' // ---- Types ---- @@ -56,18 +57,15 @@ const authHeaders = (): Record => { } function toastManager() { - const [toast, setToast] = useState('') - const show = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) } - return { toast, show } + // Legacy wrapper - now delegates to useToast via context + return { toast: '', show: (_msg: string) => {} } } export default function AdminPage({ section }: { section: Tab }) { - const { toast, show: showToast } = toastManager() + const { showToast } = useToast() return (
- {toast &&
{toast}
} -

运营后台

@@ -1369,6 +1367,9 @@ function ConfigTab({ showToast }: { showToast: (m: string) => void }) { setIntegrationForm({ ...integrationForm, endpoint: e.target.value })} placeholder="http://100.111.84.16:3002/mcp" className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" /> +

+ 必须是 Streamable HTTP 协议的 MCP 端点 (POST 方法)。例: http://host:port/mcp +

diff --git a/apps/web/src/components/ComparisonPage.tsx b/apps/web/src/components/ComparisonPage.tsx index 957fdb2..fd935be 100644 --- a/apps/web/src/components/ComparisonPage.tsx +++ b/apps/web/src/components/ComparisonPage.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useRef } from 'react' import { API_BASE } from '@/lib/apiBase' +import { useToast } from '@/components/ui/Toast' +import { SkeletonTable } from '@/components/ui/Skeleton' interface SupplierInfo { id: string @@ -45,7 +47,7 @@ export default function ComparisonPage() { const [report, setReport] = useState(null) const [loading, setLoading] = useState(true) const [suppliers, setSuppliers] = useState([]) - const [toast, setToast] = useState('') + const { showToast } = useToast() const [sortBy, setSortBy] = useState<'price' | 'name' | 'savings'>('price') const [splitSupplierCount, setSplitSupplierCount] = useState(null) @@ -55,11 +57,6 @@ export default function ComparisonPage() { const [dragOver, setDragOver] = useState(false) const fileRef = useRef(null) - const showToast = (msg: string) => { - setToast(msg) - setTimeout(() => setToast(''), 2500) - } - const loadData = async () => { try { const [compRes, suggRes] = await Promise.all([ @@ -134,7 +131,7 @@ export default function ComparisonPage() { const allSupplierNames = [...new Set(data.flatMap(i => i.suppliers.map(s => s.name)))].filter(Boolean) - if (loading) return
加载中...
+ if (loading) return return (
@@ -629,12 +626,6 @@ export default function ComparisonPage() {
)} - {/* Toast */} - {toast && ( -
- {toast} -
- )}
) } \ No newline at end of file diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9bb03ca --- /dev/null +++ b/apps/web/src/components/ErrorBoundary.tsx @@ -0,0 +1,79 @@ +'use client' + +import { Component, type ReactNode } from 'react' + +interface Props { + children: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('[ErrorBoundary]', error, errorInfo) + } + + handleRetry = () => { + this.setState({ hasError: false, error: null }) + } + + render() { + if (this.state.hasError) { + return ( +
+
+
+ + + +
+
+

页面出错了

+

+ 渲染过程中发生了未知错误,请尝试刷新页面或点击下方重试按钮。 +

+
+ {this.state.error && ( +
+                {this.state.error.message}
+              
+ )} +
+ + +
+
+
+ ) + } + + return this.props.children + } +} diff --git a/apps/web/src/components/ImagePage.tsx b/apps/web/src/components/ImagePage.tsx index 95c9265..e1d46d8 100644 --- a/apps/web/src/components/ImagePage.tsx +++ b/apps/web/src/components/ImagePage.tsx @@ -4,6 +4,9 @@ import { useEffect, useState, useRef, useCallback } from 'react' import JSZip from 'jszip' import { saveAs } from 'file-saver' import { API_BASE } from '@/lib/apiBase' +import { useToast } from '@/components/ui/Toast' +import Lightbox from '@/components/ui/Lightbox' +import { SkeletonCard } from '@/components/ui/Skeleton' interface ImageRecord { id: string @@ -45,8 +48,8 @@ export default function ImagePage() { const [selectedStyle, setSelectedStyle] = useState('white') const [selectedSize, setSelectedSize] = useState('1:1') const [selectedIds, setSelectedIds] = useState>(new Set()) - const [toast, setToast] = useState('') const [previewUrl, setPreviewUrl] = useState(null) + const { showToast } = useToast() // Upload state const [dragOver, setDragOver] = useState(false) @@ -59,11 +62,6 @@ export default function ImagePage() { const doneImages = images.filter(i => i.status === 'done') const matchedImages = images.filter(i => i.status === 'done' && Object.values(matchMap).includes(i.id)) - const showToast = (msg: string) => { - setToast(msg) - setTimeout(() => setToast(''), 2500) - } - useEffect(() => { fetch(API) .then(r => r.json()) @@ -292,7 +290,19 @@ export default function ImagePage() { } }, [matchMap, productList, images]) - if (loading) return
加载中...
+ if (loading) return ( +
+
+
+
+
+
+
+
+ {Array.from({ length: 12 }).map((_, i) => )} +
+
+ ) return (
@@ -649,23 +659,7 @@ export default function ImagePage() {
{/* Preview Lightbox */} - {previewUrl && ( -
setPreviewUrl(null)}> -
e.stopPropagation()}> - 预览 - -
-
- )} - - {/* Toast */} - {toast && ( -
- {toast} -
- )} + setPreviewUrl(null)} />
) } diff --git a/apps/web/src/components/InquiryPage.tsx b/apps/web/src/components/InquiryPage.tsx index 3c41193..3fe736c 100644 --- a/apps/web/src/components/InquiryPage.tsx +++ b/apps/web/src/components/InquiryPage.tsx @@ -2,6 +2,9 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react' import { API_BASE } from '@/lib/apiBase' +import { useToast } from '@/components/ui/Toast' +import Modal from '@/components/ui/Modal' +import { SkeletonTable } from '@/components/ui/Skeleton' interface Lead { id: string @@ -95,6 +98,7 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri const [showSModal, setShowSModal] = useState(false) const [showPubModal, setShowPubModal] = useState(false) const [pubLoading, setPubLoading] = useState(false) + const [pubForm, setPubForm] = useState({ contactName: '', contactPhone: '', deliveryAddress: '' }) const [publishFailModal, setPublishFailModal] = useState<{ failed: Array<{ title: string; error: string }> success: number @@ -104,11 +108,9 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri const [batchStatus, setBatchStatus] = useState('跟进中') const [batchLoading, setBatchLoading] = useState(false) const [batchNote, setBatchNote] = useState('') - const [toast, setToast] = useState('') + const { showToast } = useToast() const pageSize = 10 - const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) } - const [fTime, setFTime] = useState('全部日期') const [fStar, setFStar] = useState('全部星级') const [fStatus, setFStatus] = useState('全部状态') @@ -224,6 +226,13 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri const oneClickPublish = () => { const selected = filtered.filter(l => selectedIds.has(l.id)) if (selected.length === 0) return + // 从第一个选中线索预填联系人/地址 + const first = selected[0] + setPubForm({ + contactName: first.contact || '', + contactPhone: first.phone || '', + deliveryAddress: first.regionDetail || first.region || '', + }) setShowPubModal(true) } @@ -243,7 +252,15 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri const failed: Array<{ title: string; error: string }> = [] for (const lead of selected) { try { - const res = await fetch(`${API}/${lead.id}/publish`, { method: 'POST' }) + const res = await fetch(`${API}/${lead.id}/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contactName: pubForm.contactName || undefined, + contactPhone: pubForm.contactPhone || undefined, + deliveryAddress: pubForm.deliveryAddress || undefined, + }), + }) if (res.ok) { lead.publish = '已发布' success++ @@ -530,7 +547,7 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri {loading ? ( - 加载中... + ) : pageLeads.length === 0 ? ( 暂无数据 ) : pageLeads.map(lead => { @@ -737,117 +754,144 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri )} {/* S级甲方动态 Modal */} - {showSModal && ( -
setShowSModal(false)}> -
e.stopPropagation()}> -
-

- - S级甲方动态 -

- -
-
-
- - 近7日动态 · 按时间倒序排列 -
-
- {sSorted.map(l => ( -
-
-
- -
-
{l.title}
-
{l.org} · {l.cat}
-
-
-
- {l.level} -
截止 {l.deadline}
-
-
-
- {l.status} - {'★'.repeat(l.star)}{'☆'.repeat(5 - l.star)} -
+ setShowSModal(false)} + title="S级甲方动态" + subtitle="近7日动态 · 按时间倒序排列" + width="w-[520px]" + > +
+ {sSorted.map(l => ( +
+
+
+ +
+
{l.title}
+
{l.org} · {l.cat}
- ))} +
+
+ {l.level} +
截止 {l.deadline}
+
+
+
+ {l.status} + {'★'.repeat(l.star)}{'☆'.repeat(5 - l.star)}
-
+ ))}
- )} + {/* 一键发布 Modal */} {showPubModal && (() => { const lead = filtered.find(l => selectedIds.has(l.id)) if (!lead) return null + const selectedCount = filtered.filter(l => selectedIds.has(l.id)).length return ( -
{ if (!pubLoading) setShowPubModal(false) }}> -
e.stopPropagation()}> -
-

一键发布 direv

- -
-
-
即将发布以下线索至 direv.com.cn:
-
- {lead.title}
- 品类:{lead.cat} | 数量:待确认 | 截止:{lead.deadline}
- 联系:郑钊 15532027557 + + + } + > +
+ {/* 询价信息 */} +
+

— 询价信息(从线索自动填充)

+
+
询价标题:{lead.title}
+
采购产品:{lead.title.slice(0, 40)}
+
+ 数量:{lead.quantity || '待确认'} + 单位:{lead.unit || '批'} +
+
品类:{lead.cat || '未分类'} 截止:{lead.deadline || '无'}
-
将自动匹配联系人、收货地址等信息后发布。
-
- - +
+ + {/* 收货信息(用户可编辑) */} +
+

— 收货信息 *必填

+
+
+ + setPubForm({ ...pubForm, contactName: e.target.value })} + placeholder="线索联系人" + className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs outline-none focus:border-secondary" /> +
+
+ + setPubForm({ ...pubForm, contactPhone: e.target.value })} + placeholder="必填,请输入手机号" + className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs outline-none focus:border-secondary" + required /> + {!pubForm.contactPhone &&

⚠ 必填

} +
+
+ + setPubForm({ ...pubForm, deliveryAddress: e.target.value })} + placeholder="必填,请输入收货地址" + className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs outline-none focus:border-secondary" + required /> + {!pubForm.deliveryAddress &&

⚠ 必填

} +
+
+ + +
+
+ + +
+
+
+ + {/* 业务默认值 */} +
+

— 业务默认值(企业配置,不可修改)

+
+
询价主体:郑州巨变网络科技有限公司
+
采购类型:公开采购 (1)
+
开票要求:增值税专票 (2)
+
运费承担:含运费 (1)
+
报价要求:需要报价 (1)
+
付款方式:企业默认(不传)
-
+ ) })()} {/* 发布失败 Modal */} {publishFailModal && ( -
setPublishFailModal(null)}> -
e.stopPropagation()}> -
-
-

- - - - 发布失败 -

-

- 以下 {publishFailModal.failed.length} 条线索未成功发布到 B2B 平台 - {publishFailModal.success > 0 ? `(成功 ${publishFailModal.success} 条)` : ''} - ,请检查 MCP 对接配置后重试。 -

-
- -
-
- {publishFailModal.failed.map((f, i) => ( -
-
{f.title}
-
{f.error}
-
- ))} -
-
+ setPublishFailModal(null)} + title="发布失败" + subtitle={`以下 ${publishFailModal.failed.length} 条线索未成功发布到 B2B 平台${publishFailModal.success > 0 ? `(成功 ${publishFailModal.success} 条)` : ''},请检查 MCP 对接配置后重试。`} + width="w-[640px]" + footer={ + <> { e.preventDefault(); setPublishFailModal(null); window.open('http://localhost:3001/#config', '_blank') }} @@ -858,144 +902,139 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri -
+ + } + > +
+ {publishFailModal.failed.map((f, i) => ( +
+
{f.title}
+
{f.error}
+
+ ))}
-
+ )} {/* 分配负责人 Modal */} - {batchModal === 'assign' && ( -
{ if (!batchLoading) setBatchModal(null) }}> -
e.stopPropagation()}> -
-

分配负责人

- -
-
-
将以下 {selectedList.length} 条线索分配给:
- -
- - -
-
-
+ setBatchModal(null)} + title="分配负责人" + width="w-[480px]" + loading={batchLoading} + footer={ + <> + + + + } + > +
+
将以下 {selectedList.length} 条线索分配给:
+
- )} +
{/* 批量转询价 Modal */} - {batchModal === 'rfq' && ( -
{ if (!batchLoading) setBatchModal(null) }}> -
e.stopPropagation()}> -
-

批量转询价

- -
-
-
即将为以下 {selectedList.length} 条线索创建询价单:
-
- {selectedList.map(l =>
· {l.title}
)} -
-
- 指派负责人 - -
-
- 备注 -