Compare commits

..

No commits in common. "feature/zou/f002-p1-notes" and "main" have entirely different histories.

64 changed files with 1474 additions and 6042 deletions

8
.gitignore vendored
View File

@ -8,7 +8,6 @@ dist/
.next/ .next/
out/ out/
build/ build/
*.tsbuildinfo
# Local data # Local data
*.db *.db
@ -42,15 +41,8 @@ pnpm-debug.log*
# OS # OS
Thumbs.db Thumbs.db
# Uploads (不上传图片文件)
apps/api/public/uploads/
# Backup files (防误提交) # Backup files (防误提交)
*.bak *.bak
*.tmp *.tmp
*.orig *.orig
*.rej *.rej
# Local tooling dirs (不上传)
.omo/
.zcode/

291
AGENTS.md
View File

@ -2,48 +2,6 @@
语言:中文。所有对话、注释、文档、提交信息均使用中文。 语言:中文。所有对话、注释、文档、提交信息均使用中文。
## 🤝 协作流程标准(避免理解不一致造成返工)
**所有具体问题的修改,必须按这个流程执行:**
```
1. 用户描述需求(业务场景、看到的问题、截图、日志)
↓
2. 协作者反问 7 个技术维度(场景/触发/重要程度/数据/边界/样式/部署)
↓
3. 对齐范围:用户确认 + 协作者列技术方案
↓
4. 实施 + 验证
↓
5. 用户看效果,反馈或继续下一项
```
### 协作者必须问的 7 个技术维度
| 维度 | 问什么 |
|------|--------|
| **场景** | 哪个页面/哪个模块?用户路径是什么? |
| **触发条件** | 什么时候出现?手动还是自动?频率? |
| **重要程度** | 阻塞核心流程 / 体验优化 / 锦上添花? |
| **数据** | 影响多少数据?要不要数据迁移? |
| **边界** | 空数据/异常情况怎么处理? |
| **样式** | 改动大吗?要不要出截图对比? |
| **生产部署** | 改完要不要立即部署?还是攒一起? |
### 原则
- **不擅自开干**:未对齐范围前不做实现
- **不预设方案**:用户描述 ≠ 协作者的解决方案,先问清楚
- **不省略验证**:改完必须跑端到端测试,不只是"代码看起来对"
- **不静默改大动作**:涉及数据库结构、生产配置、依赖升级等要明确告知
### 例外(不需要问的简单场景)
- 拼写错误、明显笔误
- 单文件 typo 修复
- 用户明确说"按你判断做"
- 紧急 bug 修复(事后补问)
## 项目结构 ## 项目结构
``` ```
@ -85,13 +43,10 @@ npm run dev:stop # kill PID 文件中记录的进程
|---|---|---| |---|---|---|
| **3001** | 平台助手 web (Next.js) | `npm run dev:web` 或 `npm run dev` | | **3001** | 平台助手 web (Next.js) | `npm run dev:web` 或 `npm run dev` |
| **4000** | 平台助手 API (NestJS) | `npm run dev:api` 或 `npm run dev` | | **4000** | 平台助手 API (NestJS) | `npm run dev:api` 或 `npm run dev` |
| **3002** | devbox MCP server (Node.js 版 `product_network_mcp`) | `ssh devbox` + PM2, **不在 Mac 本地** | | **3002** | devbox MCP server (Node.js) | `ssh devbox` + PM2, **不在 Mac 本地** |
| **8095** | devbox MCP server (Java 版 `industry-mcp-server` Docker) | `ssh devbox` + `docker ps` 看 `industry-mcp-server`, **不在 Mac 本地** |
注意:**3002 / 8095 都是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。 注意:**3002 是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。
打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002/8095)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用。 打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002)。
> ⚠️ 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 入口。 AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP server 地址 (填到 ConfigTab 的"endpoint" 字段), 不是 web 入口。
@ -125,7 +80,7 @@ AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP serv
| ImagePage | ✅ GET `api/images`,POST generate/parse(MiniMax 生图) | | ImagePage | ✅ GET `api/images`,POST generate/parse(MiniMax 生图) |
| ProductPage | ✅ GET/POST/PATCH/DELETE `api/products` + POST upload-image | | ProductPage | ✅ GET/POST/PATCH/DELETE `api/products` + POST upload-image |
| OverviewPage | ✅ Promise.all([GET `api/leads/stats`, GET `api/leads`]) | | 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 | | LeadDetailPage | ✅ GET `api/leads/{id}`,POST to-rfq/to-collab/publish/follow-up |
| RuleCenterPage | 含"采集器配置"Tab:GET/POST/PUT `api/crawlers`,POST run/pause/resume | | RuleCenterPage | 含"采集器配置"Tab:GET/POST/PUT `api/crawlers`,POST run/pause/resume |
| ReportCenterPage | 纯 mock | | ReportCenterPage | 纯 mock |
@ -139,72 +94,7 @@ AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP serv
## 数据库模型 ## 数据库模型
User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Report, AuditLog, CrawlerConfig(含 keywords/categories + cron schedule), Inquiry, Product(含 supplier/imageUrl), GeneratedImage, **ProductCategory**(品类字典,AI 关键词反哺用) User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Report, AuditLog, CrawlerConfig(含 keywords/categories + cron schedule), Inquiry, Product(含 supplier/imageUrl), GeneratedImage
## 品类方案 C(关键词优先 + AI 反哺,2026-07-01)
### 数据模型
`ProductCategory` 表:
- `name`:品类名(2-6 中文字,唯一)
- `keywords`:JSON 数组,关键字列表
- `isAIGenerated`:AI 自动创建标记(人工 review 后可改 false)
### 分类逻辑
```
1. 采集器入库 / 手动回填时,对每条线索标题做:
关键词匹配(ProductCategory.keywords contains 标题)→ 命中直接写
↓ 未命中
MiniMax API 分类 → 写品类 + 反写关键词到 ProductCategory
2. 同类标题下次直接走关键词,不再调 AI
```
### 关键代码路径
| 文件 | 说明 |
|------|------|
| `apps/api/prisma/schema.prisma` | `ProductCategory` model |
| `apps/api/src/product-category/` | CRUD + 关键词匹配 service |
| `apps/api/src/product-category/category-inference.service.ts` | AI 推断 + 反写关键词 |
| `apps/api/src/crawler/crawler.service.ts` | 采集入库用 `batchInfer()` |
| `apps/api/src/crawler/notes-extractor.service.ts` | PDF/HTML 提取要点也走 AI |
| `apps/web/src/components/InquiryPage.tsx` | 品类筛选项动态从 `/api/categories` 加载 |
### LLM 配置(DeepSeek → MiniMax 迁移)
- endpoint:`https://api.minimaxi.com/v1/chat/completions`
- model:`MiniMax-M2.7`(env `MINIMAX_MODEL` 可覆盖)
- env:`MINIMAX_API_KEY` 写入 `apps/api/.env`(生产已配)。本地开发 `.env` 也需要配,否则物料提取/category-inference 走兜底
- **注意**:M2.7 是推理模型,max_tokens 要 ≥500 才够(否则全被 `<think>` 吃掉)。物料提取正文用 2000,标题提取用 800
- 生产 `.env` 同时保留 `MINIMAX_API_KEY` 和 `MINIMAX_MODEL` 两个变量
### 历史回填策略(重要:AI 不可全信)
2026-07-01 用 MiniMax 回填了 507 条历史空品类线索,结果:
- ✅ 命中:693/761(91%)已分配品类
- ⚠️ AI 自动新建 ~25 个细分类,需要人工 review 合并/删除
- ❌ 68 条空品类线索(重要数据手动指定,非重要忽略)
**决策(用户)**:停止继续调 AI 回填剩余数据。原因:
- 单条 API 推理模型耗时大(500 tokens 给思考留空间)
- AI 输出容易混入 `<think>`、`让我分析`、`**加粗**` 等解释文字
- 关键词匹配已能覆盖新采集数据,老数据人工 review 更可靠
### 后续流程
- **新采集数据**:自动走关键词匹配(15 个初始品类 + 已入库的 ~25 个细分类)
- **AdminPage → 品类管理**:用户手动 review AI 新建的细分类(合并到已有大类、删除不合理的)
- **空品类历史线索**:用户从详情面板手动指定,或忽略(过期数据)
- **新增采集场景需要新关键词**:编辑 `ProductCategory.keywords` 字段(AdminPage 或直接 SQL)
### 数据维护脚本
| 脚本 | 用途 |
|------|------|
| `scripts/backfill_category.js` | 批量回填空品类(已停止使用,仅供参考) |
| `scripts/clean_category.js` | 清理 AI 输出脏值(`<think>`、`**加粗**`、` - 描述`、解释行) |
| `scripts/deploy.sh` | 部署时自动同步上述脚本到生产 |
## 采集器关键事实 ## 采集器关键事实
@ -219,13 +109,7 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
3. 无需改代码,`runGenericScript()` 自动推断脚本和 JSON 文件路径 3. 无需改代码,`runGenericScript()` 自动推断脚本和 JSON 文件路径
- `CrawlerConfig` 新增 `sourceName`(线索来源显示名)和 `scriptPath`(脚本文件名,可选,默认从 type 推断) - `CrawlerConfig` 新增 `sourceName`(线索来源显示名)和 `scriptPath`(脚本文件名,可选,默认从 type 推断)
## 生产部署(腾讯云 172.81.241.109 / direv.cloud) ## 生产部署(腾讯云 124.222.182.242 / direv.cloud)
> ⚠️ 注意:服务器 IP 以 `scripts/deploy.sh` 里的 `SSH_HOST` 为准(当前 172.81.241.109)。
> 历史曾用 124.222.182.242,已失效(SSH 22 端口超时)。如遇 IP 变更,改 deploy.sh 即可。
- **一键部署**:`bash scripts/deploy.sh`(build + rsync dist/.next/源码 + pm2 restart,密码内置于脚本)
- **SSH**:`sshpass -p 'Jubianshuzi@2026' ssh ubuntu@172.81.241.109`(需本地装 sshpass)
- **路径**:`/opt/pingtai/`(代码) + `/data/pingtai/`(持久化数据,dev.db / uploads / library / 招标公告) - **路径**:`/opt/pingtai/`(代码) + `/data/pingtai/`(持久化数据,dev.db / uploads / library / 招标公告)
- **进程**:`pm2` 托管 `pingtai-api`(NestJS 4000)+ `pingtai-web`(Next.js 3001),systemd 自启(`/etc/systemd/system/pm2-ubuntu.service`) - **进程**:`pm2` 托管 `pingtai-api`(NestJS 4000)+ `pingtai-web`(Next.js 3001),systemd 自启(`/etc/systemd/system/pm2-ubuntu.service`)
@ -236,7 +120,6 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
2. `rsync` 同步 dist/.next + 源码到 `/opt/pingtai/` 2. `rsync` 同步 dist/.next + 源码到 `/opt/pingtai/`
3. `pm2 restart pingtai-api pingtai-web`(注意 `pm2 restart` 默认不更新环境变量,要 `--update-env`) 3. `pm2 restart pingtai-api pingtai-web`(注意 `pm2 restart` 默认不更新环境变量,要 `--update-env`)
4. Prisma 变更后必须先 `npx prisma migrate deploy && npx prisma generate`(在 `apps/api/` 下) 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` - `pm2 list` / `pm2 logs` / `pm2 restart pingtai-api`
- `tail -f /var/log/pingtai/api.out-1.log` - `tail -f /var/log/pingtai/api.out-1.log`
@ -256,7 +139,6 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
| `CrawlerService.run()` | method | apps/api/src/crawler/crawler.service.ts | 采集器入口,execSync 调度脚本 | | `CrawlerService.run()` | method | apps/api/src/crawler/crawler.service.ts | 采集器入口,execSync 调度脚本 |
| `ImageService` | class | apps/api/src/image/image.service.ts | 三阶段生图(图库→Bing→AI) | | `ImageService` | class | apps/api/src/image/image.service.ts | 三阶段生图(图库→Bing→AI) |
| `runGenericScript()` | method | apps/api/src/crawler/crawler.service.ts | 通用化采集器调度 | | `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 候选 | | `search_image.py` | script | scripts/search_image.py | Bing 搜索 top N 候选 |
| `process_image.py` | script | scripts/process_image.py | 去水印+裁剪+白底合成 | | `process_image.py` | script | scripts/process_image.py | 去水印+裁剪+白底合成 |
@ -267,34 +149,21 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
``` ```
平台助手 NestJS (4000) 平台助手 NestJS (4000)
↓ IntegrationService.callTool() ↓ IntegrationService.callTool()
↓ MCP 协议 (Streamable HTTP, POST 方法) ↓ MCP 协议 (Streamable HTTP)
↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key) ↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key)
renren-mcp-server renren-mcp-server (Node.js, devbox:3002)
├─ Node.js 版 (devbox:3002, product_network_mcp)
└─ Java 版 (devbox:8095, industry-mcp-server Docker 容器)
↓ 直调 Java 业务方法 ↓ 直调 Java 业务方法
↓ ↓
product_network 7 个 Java 服务 product_network 7 个 Java 服务
``` ```
注: MCP server 端口 **3002(Node)和 8095(Java Docker)都是 devbox 开发机上的端口**, 不是平台助手本地端口 (3001/4000)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用, 申请时确认走哪一套。 注: MCP server 端口 3002 是 **devbox 开发机** 上的端口, 不是平台助手本地端口 (3001/4000)。
### 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) - **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4)
- **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销) - **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销)
- **APIKey 认证**:Header `x-app-id` + `x-app-secret`(多租户, 替代旧的 `x-api-key`), AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex) - **APIKey 认证**:Header `x-api-key`,AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex)
- **公司账号代理**:每客户 1 个 `sys_user` (user_type=99) - **公司账号代理**:每客户 1 个 `sys_user` (user_type=99)
### 关键代码路径 ### 关键代码路径
@ -302,15 +171,11 @@ product_network 7 个 Java 服务
| 文件 | 说明 | | 文件 | 说明 |
|------|------| |------|------|
| `apps/api/prisma/schema.prisma` | `Integration` 表(MCP 连接配置) | | `apps/api/prisma/schema.prisma` | `Integration` 表(MCP 连接配置) |
| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool)+ **session 管理 + charset 修复** | | `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool) |
| `apps/api/src/integration/integration.controller.ts` | REST API(CRUD + test + connect) | | `apps/api/src/integration/integration.controller.ts` | REST API(CRUD + test + connect) |
| `apps/api/src/integration/integration.module.ts` | NestJS 模块 | | `apps/api/src/integration/integration.module.ts` | NestJS 模块 |
| `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool + **可选 overrides 参数** | | `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool |
| `apps/api/src/lead/lead.controller.ts` | `POST /api/leads/:id/publish` 新增可选 body | | `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面) |
| `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 协议提示文案 |
### 使用流程 ### 使用流程
@ -326,50 +191,13 @@ product_network 7 个 Java 服务
const client = new Client({ name: 'platform-assistant', version: '1.0.0' }) const client = new Client({ name: 'platform-assistant', version: '1.0.0' })
const transport = new StreamableHTTPClientTransport( const transport = new StreamableHTTPClientTransport(
new URL(endpoint), 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) await client.connect(transport)
const tools = await client.listTools() const tools = await client.listTools()
await client.callTool({ name: 'publish_rfq', arguments: { title, ... } }) 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<integrationId, sessionId>`
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 端点 ### API 端点
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
@ -392,97 +220,6 @@ if (ct && !ct.includes('charset') && ct.includes('application/json')) {
- 连接状态存储在数据库,服务重启后需重新连接 - 连接状态存储在数据库,服务重启后需重新连接
- 发布失败降级为本地模式(生成假 inquiryNo) - 发布失败降级为本地模式(生成假 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 ## ANTI-PATTERNS
- 前端不加路由库,保持 SPA useState 模式 - 前端不加路由库,保持 SPA useState 模式

View File

@ -1,448 +0,0 @@
# 平台助手 — 项目交接文档
**交接日期:** 2026-09-30
**交接对象:** 接手继续开发的智能体 / 开发者
**文档目标:** 让接手方无需历史对话即可完整了解项目现状、架构、业务逻辑、已知问题与待办事项,可直接开始开发。
---
## 1. 项目是什么
**平台助手(内部代号 pingtai)** 是面向 B2B 采购场景的智能询比价工作台:
- **采集**:自动抓取 3 家央企招标公告(中电建 / 新矿集团 / 中核集团),关键词+AI 双通道分类入库
- **线索管理**:线索列表 / 详情 / 星级评分 / 状态流转(待复核 → 询价中 → 协作中)/ 跟进日志
- **询价发布**:勾选线索一键发布到外部 B2B 产业平台(通过 MCP 通道调 `publish_rfq`),含发布前确认弹窗(补联系人/电话/地址)、物料清单 AI 提取、超长标题 AI 压缩
- **比价**:供应商报价 Excel/CSV 上传 → 自动解析 → 商品对齐去重 → 三种采购策略对比(集中/拆分/限 K 家)→ 采购建议报告 + CSV 导出
- **生图**:商品图片三阶段生成(图库 → Bing 搜索 → MiniMax AI 生成)
- **品类库**:关键词优先 + AI 反哺的品类字典(ProductCategory)
- **管理后台**:租户/用户/系统配置/计划/MCP 对接配置
**在线地址(生产):** https://direv.cloud(Nginx → Next.js 3001 / NestJS 4000)
**演示账号:** `admin` / `admin123`(username + password,**不是 email**)
---
## 2. 技术栈与架构
| 层 | 技术 | 说明 |
|----|------|------|
| 前端 | Next.js 14 App Router + React 18 + Tailwind CSS 3 | **SPA 无路由**,useState 切换 14 个页面组件 |
| 后端 | NestJS 10 + TypeScript | 13 个子模块,端口 4000 |
| 数据库 | SQLite + Prisma ORM | `apps/api/prisma/dev.db`,15 个模型 |
| 采集脚本 | Node.js / Python | `scripts/tender-fetcher/`,CrawlerService 用 `execSync` 调度 |
| AI | MiniMax M2.7 (推理模型) | 品类分类 / 物料提取 / 标题压缩,endpoint `https://api.minimaxi.com/v1/chat/completions` |
| 外部对接 | MCP (Streamable HTTP, POST) | 集成 Integration 表 → product_network B2B 平台 |
| 部署 | 腾讯云 172.81.241.109 + PM2 + Nginx + certbot | `scripts/deploy.sh` 一键部署 |
### 架构图
```
┌─────────────────────────────┐
浏览器(3001) ←│ Next.js 14 (pingtai-web) │
└─────────────────────────────┘
│ /api/*
┌────────▼─────────────────────┐
│ NestJS 10 (pingtai-api:4000) │
│ ├─ crawler: execSync 调脚本 │
│ ├─ lead: 评分/状态/发布 │
│ ├─ integration: MCP client │
│ └─ comparison/image/... │
└────────┬─────────┬───────────┘
│ │
SQLite(dev.db) MiniMax API
│
┌────────▼─────────────────────┐
│ MCP server (devbox:3002/8095)│
│ → product_network B2B 平台 │
└──────────────────────────────┘
```
> ⚠️ MCP server 端口 **3002(Node)/8095(Java)** 都在 devbox 开发机上,不是本地端口。平台助手本地 web 是 **3001**、API 是 **4000**。两套 MCP server 后端数据库不同,appId/appSecret 互不通用。
---
## 3. 目录结构
```
平台助手/
├── apps/
│ ├── web/ # Next.js 14 SPA
│ │ └── src/
│ │ ├── app/page.tsx # SPA 根,useState<Page> 切换页面
│ │ ├── components/ # 14 个页面 + AuthProvider/Sidebar/Providers/ErrorBoundary
│ │ └── lib/apiBase.ts# API_BASE (NEXT_PUBLIC_API_URL,本地回退 localhost:4000)
│ └── api/ # NestJS 10
│ ├── prisma/
│ │ ├── schema.prisma # 15 个模型
│ │ └── dev.db # SQLite
│ └── src/
│ ├── admin/ audit-log/ auth/ common/ comparison/ crawler/
│ ├── image/ inquiry/ integration/ lead/ product/ product-category/
│ ├── reports/ rules/ users/ prisma/
│ └── app.module.ts / main.ts
├── packages/shared/ # 共享 TS 类型(编译型,无运行时依赖)
└── scripts/
├── tender-fetcher/ # fetch-v1.2.js(中电建) fetch-xkgroup.py(新矿) fetch-cnncecp.py(中核)
├── direv-publish/ # direv 平台发布脚本(10 个 .py,历史保留)
├── parse_products.py # Excel/CSV → JSON(比价用)
├── search_image.py / process_image.py # 生图辅助
├── backfill_category.js / clean_category.js / reclassify_leads.js 等 # 数据维护
└── deploy.sh # 一键部署
```
---
## 4. 开发环境搭建
```bash
npm run setup # npm install + db:init(prisma generate → migrate → seed)
npm run dev # 同时启动前端(3001)和后端(4000)
npm run dev:api # 仅后端(nest start --watch,用 dist/ 编译产物)
npm run dev:web # 仅前端
npm run db:init # 改 schema 后必跑
npm run db:reset # 删 db 重来
npm run build # nest build + next build
npm run typecheck # tsc --noEmit 所有包(无 root tsconfig,各包独立)
npm run lint
npm run dev:daemon # nohup 后台启动(PID 写入 .dev-api.pid / .dev-web.pid)
npm run dev:stop # kill PID 文件记录的进程
```
**端口速查(本地):** web=3001, api=4000。⚠️ 本地 `next dev` 偶发 fallback 到 3000(3001 被 `admin_new` Vite 项目占用时),以终端实际打印为准。
### 后端陷阱
- `nest start --watch` 用 `dist/` 编译产物:Prisma schema 变更后必须 `npm run build -w apps/api` 重建 dist 再重启,单纯 `prisma generate` 不够
- 端口 4000 残留进程:`dev:stop` 只杀 PID 文件进程,多余进程 `lsof -ti:4000 | xargs kill -9`
- Prisma 变更流程:改 schema → `npm run db:init`
- 后端无测试框架
### 前端陷阱
- SPA 无路由:`page.tsx` 用 `useState<Page>` 切换,导航走 `onNavigate` prop,参数走 props
- 路径别名 `@/` → `src/`
- Prettier: noSemi, singleQuote, trailingComma: all, printWidth: 100
- Tailwind Token:`secondary(#0051d5)`, `border-subtle(#E2E8F0)`, `surface-container` 系
---
## 5. 数据库模型(15 个)
| 模型 | 用途 | 关键字段 |
|------|------|----------|
| **Lead** | 线索核心表 | title/noticeId/detailUrl/level(C-B-A)/status/star/amount/category/organization/region/regionDetail/source/contact/phone/deadline/quantity/unit/notes/aiScore/scoringDetails/publish/publishTime/inquiryNo/direvUrl/assignedTo/followLogs;`@@unique([source, noticeId])` 去重键 |
| **Rule** | 规则中心 | name/type/config(JSON)/enabled |
| **RuleVersion** | 规则版本 | ruleId/version/config/userId |
| **Report** | 报表 | title/type/status/config/channels/fileUrl |
| **AuditLog** | 审计日志 | action/detail/operator/ip |
| **CrawlerConfig** | 采集器配置 | name/targetUrl/status/type(bid_xxx)/sourceName/scriptPath/schedule(cron)/keywords/categories(JSON品类名数组)/lastRunAt/totalRuns/successRuns/failedRuns |
| **Inquiry** | 询价(历史表) | 类似 Lead,冗余保留 |
| **Product** | 供应商报价条目(比价) | name/spec/unit/price/quantity/category/brand/supplier/minOrder/imageUrl |
| **Tenant** | 租户 | companyName/plan/status/validUntil/platformUrl/apiKey |
| **User** | 用户 | email/username/password/role/tenantId |
| **GeneratedImage** | 生成图片 | productName/url/status/style/sourceHash |
| **ImageLibrary** | 图库 | productName/keyword/filePath/source(baidu)/style/ratio |
| **SystemConfig** | 系统配置 | key(unique)/value/group |
| **Integration** | MCP 对接配置 | name/endpoint/appId/apiKey(AES-256-GCM 加密)/customerCode/status/tools/companyUserId |
| **ProductCategory** | 品类字典 | name(unique)/keywords(JSON数组)/isAIGenerated |
### 种子数据(seed.ts)
- 15 电缆型号 × 5 供应商 = 75 Product + 管理用户(admin/admin123)
- crawlerConfig 3 条:中电建 + 新矿集团 + 中核集团(中核默认 paused)
---
## 6. API 端点总览
### 认证 `/api/auth`
| POST /api/auth/login | 登录(username+password,JWT 存 localStorage `auth`) |
| GET /api/auth/profile | 当前用户 |
### 线索 `/api/leads`(核心)
| GET /api/leads | 列表(query: status/level/search/star/source/category/assignedTo/timeRange + 分页) |
| GET /api/leads/stats | 统计(OverviewPage 用) |
| GET/PUT/DELETE /api/leads/:id | 详情/更新/删除 |
| POST /api/leads/:id/to-rfq, /to-collab | 状态流转:转询价 / 转协作 |
| POST /api/leads/:id/publish | **发布到 B2B 平台**,可选 body `{contactPhone?, contactName?, deliveryAddress?}` |
| POST /api/leads/batch-publish | body `{ids: string[]}`,循环调 publish |
| POST /api/leads/:id/follow-up (+ /:index/delete) | 跟进日志 |
| POST /api/leads/:id/level | 改优先级 |
| POST /api/leads/calculate-score | 重算 AI 评分 |
### 采集器 `/api/crawlers`
GET/POST/PUT /api/crawlers(:id) | CRUD
POST /api/crawlers/:id/run, /pause, /resume | 运行/暂停/恢复(联动 cron 注册)
### 比价 `/api/comparison`
GET /api/comparison | 商品分组比价数据
POST /api/comparison/upload | multipart file+supplier 上传报价
POST /api/comparison/suggestion | 生成采购建议报告
### 品类 `/api/categories`
GET/POST/PUT/DELETE | ProductCategory CRUD(InquiryPage 筛选项动态加载)
### 其他
- `/api/products` + upload-image(商品管理)
- `/api/images` generate/parse/approve/regenerate(生图)
- `/api/integrations` + :id/connect/disconnect/call-tool + test(MCP 配置)
- `/api/admin` tenants/users/plan-config/configs/stats(管理后台)
- `/api/rules` + versions/rollback
- `/api/reports` + :id/send
- `/api/inquiries` / `/api/users` / `/api/audit-logs` / `/api/health`
- `/api/lead-notes/extract/:leadId`(notes 提取)
---
## 7. 前端页面清单(14 个)
| 页面组件 | 功能 | 数据对接状态 |
|----------|------|---------------|
| LoginPage | 登录 | ✅ POST /api/auth/login |
| OverviewPage | 概览(统计卡片+线索列表) | ✅ Promise.all([leads/stats, leads]) |
| InquiryPage | 询价列表 + 筛选/搜索/星级/批量发布 + **发布前确认弹窗** | ✅ leads + publish(含弹窗传 overrides) |
| LeadDetailPage | 线索详情 + 状态流转 + 跟进日志 | ✅ leads/:id + 各流转端点 |
| ComparisonPage | 比价三步向导(上传→对比→建议) | ✅ comparison 三端点 |
| ProductPage | 商品管理 + 生图 | ✅ products + upload-image |
| ImagePage | 图片库/生成 | ✅ images |
| RuleCenterPage | 规则中心 + **采集器配置 Tab**(品类多选框) | ✅ crawlers + categories |
| ReportCenterPage | 报表 | ⚠️ 纯 mock |
| SystemMonitorPage | 系统监控 | ⚠️ 纯 mock |
| AdminPage | 管理后台(5 子区块:租户/用户/统计/configs/计划,configs 含 MCP 对接配置) | ✅ 已实现 |
| Sidebar / Providers / AuthProvider / ErrorBoundary | 框架 | — |
---
## 8. 核心业务流程
### 8.1 采集入库(crawler.service.ts)
```
CrawlerService.run(config)
├─ type=bid_powerchina → runPowerChina(): execSync('node fetch-v1.2.js --keyword "X"')
├─ type=bid_xkgroup → runGenericScript(): execSync('python3 fetch-xkgroup.py')
└─ 其他 bid_xxx → runGenericScript()(脚本名从 type 推断)
↓
results → categoryInference.batchInfer() # 品类推断(关键词匹配 → 未命中走 MiniMax)
↓
upsert 到 Lead(去重键 @@unique([source, noticeId]))
```
**关键词解析(resolveKeywords,最近重构):**
1. 优先 `config.categories`(品类名数组)→ 查 ProductCategory 表把每个品类的 keywords 展开合并
2. 兜底 `config.keywords`(旧字段,直接解析)
3. 都没有 → `['']`(抓全部)
**中电建特殊处理:** 每个关键词有独立状态文件 `downloaded_<keyword>.json`(不是只有一个 downloaded.json);结果从 `~/Desktop/招标公告/<date>/downloaded_<kw>.json` 读取。`~/desktop` 是 symlink → `/data/pingtai`(生产持久化)。
**公告 ID 提取坑:** `substr(summary, instr(summary, '公告ID:') + 6, 32)`(`+6` 而非 `+8`,字节/字符偏移曾出 bug)。
### 8.2 品类分类(品类方案 C:关键词优先 + AI 反哺)
```
采集入库/手动回填时对线索标题:
1. 关键词匹配 ProductCategory.keywords contains 标题 → 命中直接写
2. 未命中 → MiniMax API 分类 → 写品类 + 反写关键词到 ProductCategory
3. 同类标题下次直接走关键词,不再调 AI
```
- 关键文件:`product-category/category-inference.service.ts`(AI 推断+反写)、`crawler.service.ts batchInfer()`、`notes-extractor.service.ts`(PDF/HTML 提取要点)
- **MiniMax 配置**:endpoint `https://api.minimaxi.com/v1/chat/completions`,model `MiniMax-M2.7`(env `MINIMAX_MODEL` 可覆盖),key 在 `apps/api/.env` 的 `MINIMAX_API_KEY`
- **⚠️ M2.7 是推理模型**:max_tokens 要够(物料提取正文 2000、标题提取 800),否则输出全被 `<think>` 吃掉;输出常混 `<think>`、`让我分析`、`**加粗**` 等解释文字,需要清洗
- 历史经验:AI 不可全信,2026-07-01 曾批量回填 507 条,结果 AI 新建 ~25 个细分类需人工 review。决策:新数据走关键词,老数据人工 review
### 8.3 发布询价到 B2B 平台(lead.service.ts publishToDirev)
```
POST /api/leads/:id/publish { contactPhone?, contactName?, deliveryAddress? }
→ publishToDirev(id, overrides)
1. 组装 publishArgs(title/description/qty/notes/attachments/deadline/deliveryDate/contact...)
2. 企业级硬编码字段:
inquiryParty='郑州巨变网络科技有限公司', freightFlag=1(含运费), procurementType=1(公开采购),
invoiceType=2(增值税专票), quotationDemand=1(需要报价), validDays=7
⚠️ paymentMethod 不传(传了报"企业字典找不到")
⚠️ 上述 flag 必须 int,传字符串会报错(已修复过的坑)
3. 超长标题(>50字)→ TitleCompressorService.compress() AI 压缩 → 失败硬截断兜底
4. MaterialExtractorService.extractItems() 实时提取物料清单 items
5. integrationService.executeTool('publish_rfq', publishArgs)(retries:2)
6. 成功 → 更新 lead 状态(published/inquiryNo/direvUrl);失败 → ServiceUnavailableException
```
**publishArgs 字段映射细节:**
| 字段 | 来源 | 备注 |
|------|------|------|
| title | lead.title(>50 字先 AI 压缩) | 平台 varchar(50) 限制 |
| description | lead.notes → summary → title | W31 P1 |
| qty | lead.quantity \|\| 1 | |
| notes | lead.notes | 商家报价时看到 |
| attachments | lead.attachments(JSON) \|\| [detailUrl] | |
| deadline | lead.deadline 前 10 位 | YYYY-MM-DD |
| deliveryDate | deadline + 30 天 | |
| contactName | overrides.contactName → lead.contact → '待补充' | 弹窗可编辑 |
| contactPhone | overrides.contactPhone → lead.phone → '待补充' | 必填,弹窗补全 |
| deliveryAddress | overrides.deliveryAddress → regionDetail → region | 弹窗可编辑 |
| userId/enterpriseId | Integration.companyUserId | MCP connect 时获取 |
### 8.4 MCP 集成(integration.service.ts)
- **协议**:Streamable HTTP(`StreamableHTTPClientTransport`),POST 方法
- **认证头**:`x-app-id` + `x-app-secret`(多租户 APIKey,替代旧 `x-api-key`),AES-256-GCM 加密存储(`ENCRYPT_KEY` in .env,32 bytes hex)
- **Session 管理(已实现)**:`buildSessionAwareFetch(integrationId)` 自定义 fetch wrapper——从 initialize 响应头提取 `mcp-session-id` 缓存 → 后续请求自动注入 → disconnect/重连清理
- **charset 修复(已实现)**:wrapper 检测 content-type 缺 charset 时补 `application/json; charset=utf-8`(否则 Java 解析器中文 Content-Length 不匹配报 JSON 错误)
- 17 个 MCP tool(询价5/商品4/比价4/推理4),发布调 `publish_rfq`
### 8.5 标题压缩(TitleCompressorService,新增)
- 位置:`apps/api/src/lead/title-compressor.service.ts`(已注册到 LeadModule)
- 逻辑:`compress(title, maxLength)`:
1. `title.length <= maxLength` 直接返回(**≤50 字不调 AI**)
2. 未配 `MINIMAX_API_KEY` → 硬截断 `title.slice(0, maxLength)`
3. 调 MiniMax,**要求 JSON 结构化输出** `{"title":"..."}`(对抗 M2.7 多变思考噪音,行级正则过滤不可靠)
4. `extractJsonTitle()` 正则提取 title 字段;解析不到 → 硬截断
5. 结果仍超长 → slice 截断兜底
- max_tokens 800(400 会被明文思考耗尽导致答案截断)
- 生产验证:6 个真实标题全部压缩达标(62字→25字等)
### 8.6 比价(comparison.service.ts)
```
上传报价 (POST upload: file+supplier)
→ parse_products.py 解析 Excel/CSV → Product 入库
→ GET /api/comparison:
按 name.trim().toLowerCase() 分组 → 组内按 supplier 去重留最低价
→ 每组算 bestPrice/bestSupplier/savings/priceSpread
→ POST /api/comparison/suggestion:
三种策略对比:
A. 无限制拆分(每 SKU 选最低总价供应商)
B. 单一供应商(Σ 全 SKU 总价排名,取第 1)
C. 限制 K 家(C(n,K) 组合枚举 + 每 SKU 取组合内最低价,K=2/3/4)
→ recommendations 自然语言建议 + CSV 导出
```
### 8.7 生图(image.service.ts)
三阶段:图库(ImageLibrary) → Bing 搜索(search_image.py) → MiniMax AI 生成;`process_image.py` 去水印+裁剪+白底合成。
---
## 9. 已知问题 / 坑(接手必读)
### 9.1 生产部署相关(⚠️ 最近出现)
1. **页面打开报"服务器内部错误" / `Failed to find Server Action`**(web 日志常见):
- 根因:**本地 build 的 `.next/` 直接 rsync 到生产**,生产 `node_modules` 版本与本地不一致 → Server Action ID 对不上 → 页面交互报错
- **解决办法(AGENTS.md 明确记录)**:在生产服务器上执行 `cd /opt/pingtai/apps/web && npx next build` 重建 `.next`,再 `pm2 restart pingtai-web`
- 若浏览器缓存旧 HTML 导致,提示用户强刷(Cmd+Shift+R)
2. **deploy.sh 不含 DB 迁移**:Prisma 变更部署前,需在生产 `apps/api/` 下手动 `npx prisma migrate deploy && npx prisma generate`,否则 schema 不同步
3. `pm2 restart` 默认不更新环境变量,需 `--update-env`(deploy.sh 已带)
### 9.2 数据质量(采集器只抓列表页)
| 字段 | 填写率(2026-07-16 生产统计 815 条) | 说明 |
|------|------|------|
| contact | 0% | 采集器未访问详情页 |
| phone | 0% | 同上 |
| quantity | 0% | 同上 |
| unit | 0% | 同上 |
| notes | 0% | 同上 |
当前应对:发布前弹窗手动补全。自动化提取(类似 notes-extractor 的详情页 AI 提取)**待实现**。
### 9.3 MiniMax / AI
- M2.7 输出噪音(`<think>`、`让我分析`、`**加粗**`)——所有 AI 调用都要清洗
- 生产 `.env` 已配 `MINIMAX_API_KEY` + `MINIMAX_MODEL`;本地 `.env` 需自行配置,否则走兜底
- 若 `MAX_COMPRESS_LEN` 等常量调整,注意 PROMPT 里 `'45'` 是字符串 replace,保持一致
### 9.4 采集器
- 中电建 state 文件 key:`systemId_招采公告`,**不要手动删除**
- 新矿集团脚本偶尔 `ReadTimeout`(日志见 Python traceback),不影响页面,重跑即可
- 新增采集器通用流程:放脚本到 `tender-fetcher/`,输出 `~/Desktop/招标公告/<date>/xxx_downloaded.json`,前端新建 CrawlerConfig(type=`bid_xxx`、sourceName、scriptPath),无需改代码
- cron 用 6 字段格式(兼容 cron@4.4.0)
### 9.5 MCP / B2B 发布
- `code:10021 "未获取到用户信息"`:产业平台修复中(创建 APIKey 已关联 companyUserId,后端直接使用)
- `paymentMethod` 传 "23" 报"企业字典找不到" → **不传**,后端用默认
- 字段类型必须是 int(freightFlag/procurementType/invoiceType),字符串会报错
- title 超 50 字已由 TitleCompressorService 前置处理
---
## 10. 当前工作区状态(未提交改动,2026-09-30)
> ⚠️ **接手第一件事:决定这些改动是否保留/提交/继续开发。**
### 已改文件(未 commit)
| 文件 | 改动内容 | 状态 |
|------|----------|------|
| `apps/api/src/lead/title-compressor.service.ts` | **新文件**:标题 AI 压缩服务(JSON 结构化输出方案) | ✅ 已实现+生产实测通过 |
| `apps/api/src/lead/lead.module.ts` | 注册 TitleCompressorService | ✅ |
| `apps/api/src/lead/lead.service.ts` | publishToDirev 集成标题压缩(>50 字先压缩再校验) | ✅ |
| `apps/api/src/crawler/crawler.service.ts` | resolveKeywords(品类→关键词展开)+ 每关键词独立状态文件 + 日志增强 | ✅ |
| `apps/web/src/components/InquiryPage.tsx` | 新增搜索框 fSearch → GET /api/leads?search= | ✅ |
| `apps/web/src/components/RuleCenterPage.tsx` | 采集器配置从 keywords 改为 **品类多选框**(categories JSON 数组) | ✅ |
| `scripts/tender-fetcher/fetch-v1.2.js` | 中电建脚本增强(过滤/回溯) | ✅ |
### 未跟踪文件
- `scripts/cleanup_garbage_categories.sql` — 清理 AI 垃圾品类
- `scripts/fix_corrupted_titles.sql` — 修复损坏标题
- `scripts/reclassify_leads.js` / `scripts/refetch_corrupted.js` — 历史数据维护
- `PRD-智能比价助手.md` — 比价功能 PRD(供外部平台参考)
### 工作区 vs 生产差异
- **本地有大量未提交改动,生产未部署**(生产 `.next/` 构建于 2026-09-03)
- 若需上线这些改动:`npm run typecheck` → `bash scripts/deploy.sh` → **生产重建 web `.next`**(见 9.1)
---
## 11. 生产部署与运维
- **服务器**:172.81.241.109(`SSH_HOST` 在 deploy.sh,如 IP 变更改这里)
- **SSH**:`sshpass -p 'Jubianshuzi@2026' ssh ubuntu@172.81.241.109`
- **一键部署**:`bash scripts/deploy.sh`(build + rsync dist/.next/源码 + pm2 restart --update-env)
- **路径**:代码 `/opt/pingtai/`,数据 `/data/pingtai/`(dev.db / uploads / library / 招标公告)
- **进程**:pm2 托管 `pingtai-api`(4000) + `pingtai-web`(3001),systemd 自启
- **反代**:Nginx 80/443 → 3001;`/api/*` → 4000;`/uploads/*` → `/data/pingtai/uploads/`
- **HTTPS**:Let's Encrypt,certbot 自动续期 timer 已开
- **常用操作**:
```bash
pm2 list / pm2 logs pingtai-api / pm2 restart pingtai-api --update-env
tail -f /var/log/pingtai/api.out-1.log
sqlite3 /data/pingtai/dev.db # 直接查 DB
tar czf pingtai-backup-$(date +%Y%m%d).tgz /data/pingtai/
```
- **历史包袱**:服务器上旧 Postgres/Redis 部署备份在 `/home/ubuntu/platform-assistant.bak-*`,旧 nginx conf 在 `/etc/nginx/conf.d/platform.conf.bak-*`,可回退
- **采集目录 symlink**:`~/desktop -> /data/pingtai`(必须指向 `/data/pingtai`,不是 `/data/pingtai/招标公告`,否则双层嵌套)
---
## 12. 待办事项 / 建议的下一步
1. **决策未提交改动**:title-compressor + crawler 品类化 + InquiryPage 搜索 + RuleCenterPage 品类多选框,是否继续/提交/部署
2. **修复生产页面 500**:若线上仍有 Server Action 报错,生产 `npx next build` 重建(见 9.1)
3. **详情页内容提取**(P1 数据质量):采集器入库后加类似 notes-extractor 的 AI 提取步骤,补 contact/phone/quantity/unit(当前全缺)
4. **空品类历史线索 review**:AdminPage 品类管理人工 review AI 新建的细分类(合并/删除)
5. **ReportCenterPage / SystemMonitorPage**:仍是 mock,可接真实数据
6. **比价模块增强**(参考 PRD-智能比价助手.md):报价有效期、供应商画像权重、多租户
7. **MCP 发布卡点跟进**:`code:10021` 等产业平台修复状态
---
## 13. 协作约定(对后续智能体)
- 语言:中文(对话/注释/文档/commit)
- **改动前必须对齐**:涉及具体修改按 AGENTS.md 协作流程(7 个技术维度:场景/触发/重要程度/数据/边界/样式/部署),不擅自开干
- 例外:拼写错误、单文件 typo、用户明确"按你判断做"、紧急 bug
- 改完必须端到端验证,不只"代码看起来对"
- 涉及数据库结构、生产配置、依赖升级要明确告知用户
- 提交信息用中文
---
**结束语:** 本项目最核心的链路是 **采集 → 分类 → 询价 → MCP 发布**,接手时建议先跑通这条链路的本地/生产实测,再动其他模块。祝顺利!

View File

@ -1,402 +0,0 @@
# 智能比价助手 - 产品需求文档 (PRD)
**版本**: v1.0
**日期**: 2025-08
**适用平台**: 参考设计文档,供其他平台二次开发/移植使用
**源项目**: 平台助手 - 智能比价助手模块
---
## 1. 产品概述
### 1.1 产品定位
**智能比价助手** 是面向采购场景的多供应商报价自动对齐与智能决策工具。核心价值:**将非结构化的供应商报价文件(Excel/CSV)转化为结构化可比价数据,通过算法自动生成最优采购方案(拆分/集中/限制供应商数),输出可执行的采购建议报告。**
### 1.2 核心用户场景
| 场景 | 痛点 | 解决方案 |
|------|------|----------|
| 采购员收到多家供应商报价单 | 格式不一、人工对齐耗时、易漏项 | 拖拽上传 → 自动解析 → 以商品名+规格自动对齐 |
| 不知道找哪家供应商最便宜 | 需人工逐行对比、易漏最优 | 自动计算每SKU最优/次优供应商、价差、降本率 |
| 纠结是集中采购还是拆分采购 | 缺量化依据、靠经验拍脑袋 | 三大策略对比:集中采购 / 无限制拆分 / 限制K家供应商 |
| 需向领导汇报采购方案 | 缺专业报告、数据支撑弱 | 一键生成含策略对比、SKU明细、供应商画像、降本测算的结构化报告 |
---
## 2. 系统架构
### 2.1 技术栈
| 层 | 技术选型 |
|----|----------|
| 后端框架 | NestJS 10 + TypeScript |
| 数据库 | SQLite (Prisma ORM) / 可替换为 PostgreSQL/MySQL |
| 文件解析 | Python 脚本 `parse_products.py` (pandas/openpyxl) |
| 前端框架 | Next.js 14 App Router + React 18 + Tailwind CSS 3 |
| 状态管理 | React useState + useEffect (SPA 无路由模式) |
| 文件上传 | Multer + FormData 拖拽上传 |
### 2.2 模块依赖
```
ComparisonModule
├── ComparisonController (REST API)
├── ComparisonService (核心业务逻辑)
├── PrismaModule (数据库)
└── (无外部服务依赖)
```
---
## 3. 数据模型
### 3.1 核心实体:Product (供应商报价条目)
```prisma
model Product {
id String @id @default(uuid())
name String // 商品名称(去重键核心字段)
spec String // 规格型号
unit String // 单位(个/吨/米/卷...)
price Float // 单价
quantity Int // 数量/采购量
category String // 品类(用于筛选/统计)
brand String? // 品牌
supplier String // 供应商名称(去重键辅助字段)
minOrder Int? // 起订量
imageUrl String? // 图片URL
status String @default("draft") // draft/published
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
### 3.2 聚合视图:ComparisonItem (比价分析输出)
```typescript
interface ComparisonItem {
productName: string; // 商品名(去重后)
spec: string; // 规格
category: string; // 品类
quantity: number; // 采购量(取该商品所有报价的最大值)
suppliers: SupplierInfo[]; // 去重后的供应商报价列表
bestPrice: number; // 最低价
bestSupplier: string; // 最优供应商名
hasSupplier: boolean; // 是否有有效供应商报价
savings: number; // 平均价 - 最优价(单价维度)
priceSpread: number; // 最高价 - 最低价(价差)
}
interface SupplierInfo {
id: string;
name: string;
price: number;
unit: string;
spec: string;
brand?: string;
minOrder?: number;
imageUrl?: string;
}
```
### 3.3 采购建议报告
```typescript
interface SuggestionReport {
// 核心汇总
totalItems: number; // 参与比价的SKU数
totalSplitCost: number; // 无限制拆分总价
savingsVsSingle: number; // 拆分 vs 单一供应商节省金额
savingsPctVsSingle: number; // 综合降本比例
bestSingleSupplier: { name, total }; // 单一供应商最优方案
// 策略维度
supplierRanking: { name, total, skuCount }[]; // 单一供应商总价排名
splitPicks: SplitPick[]; // 无限制拆分时每SKU最优分配
splitByCount: { // 限制K家供应商时的最优组合
2: StrategyResult;
3: StrategyResult;
4: StrategyResult;
};
// 可执行建议
recommendations: string[]; // 自然语言建议列表
}
```
---
## 4. 核心 API 设计
### 4.1 REST Endpoints
| 方法 | 路径 | 说明 | 请求/响应 |
|------|------|------|-----------|
| GET | `/api/comparison` | 获取所有商品分组比价数据 | 返回 `ComparisonItem[]` |
| POST | `/api/comparison/upload` | 上传供应商报价文件 | multipart/form-data: file + supplier |
| POST | `/api/comparison/suggestion` | 生成采购建议报告 | 返回 `SuggestionReport` |
### 4.2 请求/响应示例
**上传报价**
```bash
curl -X POST /api/comparison/upload \
-F "file=@quote.xlsx" \
-F "supplier=远东电缆"
```
```json
{ "count": 45, "supplier": "远东电缆" }
```
**获取建议**
```bash
curl -X POST /api/comparison/suggestion
```
```json
{
"totalItems": 12,
"totalSplitCost": 125600.00,
"savingsVsSingle": 18500.50,
"savingsPctVsSingle": 12.8,
"bestSingleSupplier": { "name": "远东电缆", "total": 144100.50 },
"supplierRanking": [
{ "name": "远东电缆", "total": 144100.50, "skuCount": 12 },
{ "name": "宝胜电缆", "total": 152300.00, "skuCount": 10 }
],
"splitPicks": [...],
"splitByCount": { "2": {...}, "3": {...}, "4": {...} },
"recommendations": [
"本次比价共涉及 12 个SKU,2 家供应商参与报价。",
"按SKU拆分下单比统一从「远东电缆」采购可再节省 ¥18500.50(降本 12.8%)。"
]
}
```
---
## 5. 核心算法逻辑
### 5.1 数据聚合与去重 (`getComparisonData`)
```typescript
// 核心步骤:
// 1. 读取所有 Product 记录
// 2. 按 name.trim().toLowerCase() 分组(商品名去重)
// 3. 组内按 supplier 去重 → 同一供应商保留最低价
// 4. 计算每组统计指标:
// - bestPrice / bestSupplier
// - savings = avgPrice - bestPrice
// - priceSpread = maxPrice - minPrice
```
**去重策略**:
- **商品级**:`name` 忽略大小写/首尾空格分组
- **供应商级**:组内按 `supplier` 去重,保留最低价
- **数量取最大值**:`quantity = max(所有报价的 quantity)`
### 5.2 无限制拆分策略 (`getSuggestion` → splitPicks)
每个 SKU 独立选择最低总价供应商:
```
对每个 SKU:
1. 计算各供应商总价 = price * quantity
2. 选总价最低者为最优
3. 次优 = 总价第二低者
4. 单品节省 = 次优总价 - 最优总价
```
**输出**:`splitPicks[]` + `totalSplitCost` + `supplierSplitCounts`(各供应商中标SKU数)
### 5.3 单一供应商策略 (`supplierRanking`)
```
对每个供应商:
总价 = Σ(该供应商所有SKU的 price * quantity)
SKU数 = 该供应商有报价的SKU数
按总价升序排序 → bestSingleSupplier = 第1名
```
### 5.4 限制 K 家供应商策略 (`splitByCount[K]`)
**组合枚举 + 贪心分配**:
```
输入:所有供应商名单 allNames,目标数量 K (2/3/4)
对于 allNames 中所有 C(n, K) 种组合:
对每个 SKU:
从组合中选价格最低的供应商
累加总价
记录总价最低的组合
```
**复杂度**:C(n, K) × m (SKU数)。n≤10, K≤4 时可接受(C(10,4)=210)。
**输出**:
- `bestSuppliers`: 实际使用的供应商列表(去除组合中未中标者)
- `totalCost`: 最优总价
- `assignments[]`: 每SKU的分配明细
- `supplierBreakdown`: 各供应商中标SKU数/金额占比
### 5.3 采购建议生成 (`recommendations`)
基于规则引擎生成自然语言建议:
1. **概况**:SKU数、供应商数
2. **拆分 vs 集中**:若 `savingsVsSingle > 0` → 推荐拆分 + 具体金额/比例
3. **优势供应商**:`supplierSplitCounts` Top 3
4. **最大价差SKU**:`splitPicks` 中 savings 最大者
5. **风险提示**:SKU>10 时提示交付协调风险
---
## 6. 前端交互流程
### 6.1 三步向导式交互
```
Step 1: 上传报价 (upload)
├── 输入供应商名称
├── 拖拽/点击上传 .xlsx/.xls/.csv
├── Python脚本解析 → 入库
└── 显示已上传供应商列表 + 报价条数
Step 2: 比价分析 (compare)
├── 表格展示:商品名 | 规格 | 数量 | 各供应商单价 | 最优价 | 价差
├── 最优价绿色高亮,缺报显示 "-"
├── 支持按价格/名称/节省金额排序
└── 无供应商对比数据单独展示
Step 3: 采购建议 (suggestion)
├── Hero汇总卡:拆分总价 / 节省金额 / 降本率
├── 策略对比卡:
│ ├── A方案:单一供应商(含供应商总价排名表)
│ └── B方案:拆分采购(含K家供应商选择器 K=2/3/4)
│ ├── 动态条形图:供应商中标SKU占比
│ └── 明细表:每SKU分配供应商/单价/数量/总价
├── SKU明细表:推荐供应商/单价/总价/次优总价/节省/降本率
├── 采购建议列表(自然语言)
└── 导出 CSV 报告
```
### 6.2 关键交互细节
| 交互 | 实现细节 |
|------|----------|
| 文件上传 | 拖拽区域 + 点击选择,支持 .xlsx/.xls/.csv,显示上传进度 |
| 供应商名称 | 必填校验,作为报价归属标识 |
| K值选择器 | 2/3/4 三按钮,单选互斥,支持取消选中回退无限制方案 |
| 表格高亮 | 最优价格绿色背景+粗体,价差红色显示 |
| 空状态处理 | 无数据/无对比数据分别展示占位提示 |
| 导出CSV | BOM头防乱码,含完整SKU明细列 |
---
## 7. 文件解析规范
### 7.1 支持格式
- `.xlsx` / `.xls` (Excel)
- `.csv` (逗号分隔)
### 7.2 必需列映射 (parse_products.py)
| 标准列名 | 别名/兼容 | 说明 |
|----------|-----------|------|
| `title` / `name` | 商品名称、产品名称 | 必填,作为去重主键 |
| `spec` | 规格、型号 | 可选 |
| `unit` | 单位 | 默认 "个" |
| `price` | 单价、价格 | 必填,数值型 |
| `quantity` | 数量、采购量 | 默认 1 |
| `category` | 品类、分类 | 默认 "未分类" |
| `brand` | 品牌 | 可选 |
| `minOrder` | 起订量 | 可选 |
### 7.3 解析脚本接口
```python
# scripts/parse_products.py
# 输入: 文件路径
# 输出: JSON 数组到 stdout
# 格式: [{"title": "...", "spec": "...", "price": 100, ...}, ...]
# 错误: 输出 {"error": "错误信息"} 到 stdout
```
---
## 8. 部署与运维
### 8.1 环境变量
```env
DATABASE_URL=file:./dev.db # SQLite文件路径
# 或 PostgreSQL: postgresql://user:pass@host:5432/db
```
### 8.2 启动命令
```bash
# 开发
npm run dev # 同时启动前端(3001) + 后端(4000)
npm run dev:api # 仅后端
npm run dev:web # 仅前端
# 生产部署
npm run build
bash scripts/deploy.sh # 一键构建+rsync+PM2重启
```
### 8.3 生产架构
```
Nginx (80/443)
├── /api/* → NestJS:4000
├── /uploads/* → 静态文件 /data/pingtai/uploads/
└── /* → Next.js:3001
PM2 管理: pingtai-api, pingtai-web
```
---
## 9. 扩展指南
### 9.1 新增策略维度
在 `ComparisonService.getSuggestion()` 中扩展:
1. 新增 `splitByCount[K]` 的 K 值
2. 或新增约束维度(如:按品牌集中、按地区集中、按最小起订量过滤)
3. 在 `recommendations` 中增加对应建议文案
### 9.2 多租户隔离
当前单租户设计,扩展多租户需:
- `Product` 增加 `tenantId` 字段 + 复合索引
- API 增加租户上下文中间件
- `getComparisonData` / `getSuggestion` 加 `where: { tenantId }`
### 9.3 报价有效期管理
- `Product` 增加 `validFrom` / `validTo` 字段
- 查询时过滤 `validTo >= now()`
- 前端增加"报价有效期"筛选器
### 9.4 供应商画像扩展
- 新增 `Supplier` 实体:资质、交付周期、历史履约率、质量评分
- 建议算法引入权重:`综合得分 = 价格权重*0.6 + 交付权重*0.2 + 质量权重*0.2`
---
## 10. 附录:关键代码路径速查
| 功能 | 文件路径 |
|------|----------|
| 比价数据聚合 | `apps/api/src/comparison/comparison.service.ts:12-69` |
| 采购建议核心算法 | `apps/api/src/comparison/comparison.service.ts:131-314` |
| K家供应商组合优化 | `apps/api/src/comparison/comparison.service.ts:254-314` |
| 报价文件上传解析 | `apps/api/src/comparison/comparison.service.ts:71-129` |
| Python解析脚本 | `scripts/parse_products.py` |
| 前端三步向导主页 | `apps/web/src/components/ComparisonPage.tsx` |
| 比价表格渲染 | `ComparisonPage.tsx:256-320` |
| 采购建议报告UI | `ComparisonPage.tsx:327-623` |
| 导出CSV逻辑 | `ComparisonPage.tsx:606-617` |
---
## 11. 迁移清单 (给目标平台)
- [ ] 复刻 `Product` 数据模型到目标数据库
- [ ] 移植 `parse_products.py` 适配目标平台的文件存储
- [ ] 实现 `ComparisonService` 核心类(去重、聚合、三大策略算法)
- [ ] 暴露 3 个 REST API 端点
- [ ] 实现前端三步向导页面(上传/比价/建议)
- [ ] 集成文件上传组件 + 进度反馈
- [ ] 实现 CSV 导出功能
- [ ] 补充单元测试:去重逻辑、K组合枚举、边界情况(单供应商、无报价SKU)
- [ ] 压力测试:1000 SKU × 10 供应商 × K=4 组合枚举性能
---
**文档结束** — 如有疑问可参考源码实现细节。建议先跑通核心算法单元测试,再集成前后端。

View File

@ -28,7 +28,6 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdf-parse": "^2.4.5",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },

View File

@ -1,12 +0,0 @@
-- CreateTable
CREATE TABLE "ProductCategory" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"keywords" TEXT NOT NULL,
"isAIGenerated" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "ProductCategory_name_key" ON "ProductCategory"("name");

View File

@ -28,9 +28,6 @@ model Lead {
quantity Int? // 询价数量 quantity Int? // 询价数量
unit String? // 单位(件/吨/米/台等) unit String? // 单位(件/吨/米/台等)
attachments String? // JSON 数组字符串(附件 URL 列表) attachments String? // JSON 数组字符串(附件 URL 列表)
notes String? // W31 P1: 公告 6 条商务要点摘要 (PDF → DeepSeek → 商家报价时看)
notesExtractedAt DateTime? // notes 提取时间, 用于判断是否过期
notesSource String? // notes 来源 ("pdf" / "html" / "url"), 留痕
summary String? summary String?
reason String? reason String?
analysisLogic String? analysisLogic String?
@ -230,12 +227,3 @@ model Integration {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model ProductCategory {
id String @id @default(uuid())
name String @unique // 品类名, 如 "电线电缆"
keywords String // JSON 数组: ["电缆","电线","导线"]
isAIGenerated Boolean @default(false) // 是否由 AI 自动分类生成
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -120,8 +120,8 @@ async function main() {
await prisma.crawlerConfig.deleteMany() await prisma.crawlerConfig.deleteMany()
await prisma.crawlerConfig.createMany({ await prisma.crawlerConfig.createMany({
data: [ data: [
{ name: '中国电建阳光采购网', targetUrl: 'https://bid.powerchina.cn', type: 'bid_powerchina', sourceName: '中电建', status: 'active', schedule: '0 0 8 * * *', totalRuns: 286, successRuns: 275, failedRuns: 11, keywords: '["电缆","防火门","配电箱","钢材"]' }, { name: '中国电建阳光采购网', targetUrl: 'https://bid.powerchina.cn', type: 'bid_powerchina', sourceName: '中电建', status: 'active', schedule: '0 8 * * 1-5', totalRuns: 286, successRuns: 275, failedRuns: 11, keywords: '["电缆","防火门","配电箱","钢材"]' },
{ name: '新矿集团供应链平台', targetUrl: 'https://xkgroup.kdcloud.com', type: 'bid_xkgroup', sourceName: '新矿集团', scriptPath: 'fetch-xkgroup.py', status: 'active', schedule: '0 5 8 * * *', totalRuns: 142, successRuns: 138, failedRuns: 4, keywords: '["电缆","钢材","阀门","管材"]' }, { name: '新矿集团供应链平台', targetUrl: 'https://xkgroup.kdcloud.com', type: 'bid_xkgroup', sourceName: '新矿集团', scriptPath: 'fetch-xkgroup.py', status: 'active', schedule: '5 8 * * 1-5', totalRuns: 142, successRuns: 138, failedRuns: 4, keywords: '["电缆","钢材","阀门","管材"]' },
{ name: '中核集团电子采购平台', targetUrl: 'https://www.cnncecp.com', type: 'bid_cnncecp', sourceName: '中核集团', scriptPath: 'fetch-cnncecp.py', status: 'paused', totalRuns: 0, successRuns: 0, failedRuns: 0, keywords: '["电缆","防火门","钢材","阀门"]' }, { name: '中核集团电子采购平台', targetUrl: 'https://www.cnncecp.com', type: 'bid_cnncecp', sourceName: '中核集团', scriptPath: 'fetch-cnncecp.py', status: 'paused', totalRuns: 0, successRuns: 0, failedRuns: 0, keywords: '["电缆","防火门","钢材","阀门"]' },
], ],
}) })
@ -262,33 +262,6 @@ async function main() {
}) })
console.log('Seed data created') console.log('Seed data created')
const categories = [
{ name: '防火门', keywords: ['防火门', '防火卷帘', '防火窗', '钢质防火', '木质防火'] },
{ name: '配电箱', keywords: ['配电箱', '配电柜', '配电盘', '照明配电箱', '动力配电箱', '电表箱'] },
{ name: '电线电缆', keywords: ['电线', '电缆', '导线', '电力线', '控制电缆', '光缆', '架空线'] },
{ name: '阀门', keywords: ['阀门', '闸阀', '球阀', '蝶阀', '截止阀', '止回阀', '调节阀'] },
{ name: '钢材', keywords: ['钢材', '钢管', '钢板', '钢筋', '型钢', '槽钢', '角钢', '工字钢', 'H型钢', '螺纹钢'] },
{ name: '涂料', keywords: ['涂料', '油漆', '防腐漆', '防火涂料', '乳胶漆', '防水涂料'] },
{ name: '混凝土', keywords: ['混凝土', '商混', '商品砼', '水泥', '砂浆', '砼'] },
{ name: '变压器', keywords: ['变压器', '箱变', '干式变', '油浸变', '电力变压器'] },
{ name: '水泵', keywords: ['水泵', '离心泵', '潜污泵', '消防泵', '排污泵', '循环泵'] },
{ name: '风机', keywords: ['风机', '通风机', '排风机', '轴流风机', '离心风机'] },
{ name: '管材', keywords: ['管材', '管道', 'PVC管', 'PE管', '镀锌管', '无缝管', '螺旋管', '给水管'] },
{ name: '开关', keywords: ['开关', '断路器', '接触器', '继电器', '熔断器', '隔离开关'] },
{ name: '灯具', keywords: ['灯具', '照明灯', '路灯', '防爆灯', 'LED灯', '太阳能灯'] },
{ name: '仪表', keywords: ['仪表', '仪器', '传感器', '变送器', '流量计', '压力表', '温度计'] },
{ name: '电机', keywords: ['电机', '电动机', '减速机', '减速器', '变频电机'] },
]
for (const cat of categories) {
await prisma.productCategory.upsert({
where: { name: cat.name },
update: { keywords: JSON.stringify(cat.keywords) },
create: { name: cat.name, keywords: JSON.stringify(cat.keywords) },
})
}
console.log('ProductCategory seed data created')
} }
main() main()

View File

@ -13,7 +13,6 @@ import { AuthModule } from './auth/auth.module'
import { UsersModule } from './users/users.module' import { UsersModule } from './users/users.module'
import { AdminModule } from './admin/admin.module' import { AdminModule } from './admin/admin.module'
import { IntegrationModule } from './integration/integration.module' import { IntegrationModule } from './integration/integration.module'
import { ProductCategoryModule } from './product-category/product-category.module'
import { HealthController } from './common/health.controller' import { HealthController } from './common/health.controller'
@Module({ @Module({
@ -32,7 +31,6 @@ import { HealthController } from './common/health.controller'
CrawlerModule, CrawlerModule,
AuditLogModule, AuditLogModule,
IntegrationModule, IntegrationModule,
ProductCategoryModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })

View File

@ -1,13 +1,9 @@
import { Controller, Get, Post, Put, Param, Body, Query } from '@nestjs/common' import { Controller, Get, Post, Put, Param, Body } from '@nestjs/common'
import { CrawlerService } from './crawler.service' import { CrawlerService } from './crawler.service'
import { NotesExtractorService } from './notes-extractor.service'
@Controller('api/crawlers') @Controller('api/crawlers')
export class CrawlerController { export class CrawlerController {
constructor( constructor(private readonly crawlerService: CrawlerService) {}
private readonly crawlerService: CrawlerService,
private readonly notesExtractor: NotesExtractorService,
) {}
@Get() @Get()
findAll() { findAll() {

View File

@ -1,15 +1,12 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule' import { ScheduleModule } from '@nestjs/schedule'
import { PrismaModule } from '../prisma/prisma.module' import { PrismaModule } from '../prisma/prisma.module'
import { ProductCategoryModule } from '../product-category/product-category.module'
import { CrawlerService } from './crawler.service' import { CrawlerService } from './crawler.service'
import { CrawlerController } from './crawler.controller' import { CrawlerController } from './crawler.controller'
import { NotesExtractorService } from './notes-extractor.service'
@Module({ @Module({
imports: [PrismaModule, ScheduleModule.forRoot(), ProductCategoryModule], imports: [PrismaModule, ScheduleModule.forRoot()],
controllers: [CrawlerController], controllers: [CrawlerController],
providers: [CrawlerService, NotesExtractorService], providers: [CrawlerService],
exports: [NotesExtractorService],
}) })
export class CrawlerModule {} export class CrawlerModule {}

View File

@ -5,7 +5,6 @@ import { execSync } from 'child_process'
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs' import * as fs from 'fs'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import { CategoryInferenceService } from '../product-category/category-inference.service'
const SCRIPTS_DIR = path.resolve(__dirname, '../../../../../scripts') const SCRIPTS_DIR = path.resolve(__dirname, '../../../../../scripts')
const TENDER_FETCHER_DIR = path.join(SCRIPTS_DIR, 'tender-fetcher') const TENDER_FETCHER_DIR = path.join(SCRIPTS_DIR, 'tender-fetcher')
@ -19,8 +18,6 @@ interface FetchResult {
noticeId: string noticeId: string
detailUrl: string detailUrl: string
amount?: string amount?: string
region?: string
regionDetail?: string
} }
@Injectable() @Injectable()
@ -30,7 +27,6 @@ export class CrawlerService implements OnModuleInit {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private schedulerRegistry: SchedulerRegistry, private schedulerRegistry: SchedulerRegistry,
private categoryInference: CategoryInferenceService,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@ -113,21 +109,14 @@ export class CrawlerService implements OnModuleInit {
let results: FetchResult[] = [] let results: FetchResult[] = []
if (config.type === 'bid_powerchina') { if (config.type === 'bid_powerchina') {
results = await this.runPowerChina(config) results = await this.runPowerChina()
} else { } else {
results = await this.runGenericScript(config) results = await this.runGenericScript(config)
} }
this.logger.log(`脚本返回 ${results.length} 条原始结果`)
let successCount = 0 let successCount = 0
for (const r of results) {
const validResults = results.filter(r => r.noticeId) if (!r.noticeId) continue
this.logger.log(`有效结果 ${validResults.length} 条 (有 noticeId)`)
const categoryMap = await this.categoryInference.batchInfer(
validResults.map(r => ({ id: r.noticeId, title: r.title })),
)
for (const r of validResults) {
try { try {
await this.prisma.lead.upsert({ await this.prisma.lead.upsert({
where: { where: {
@ -139,10 +128,7 @@ export class CrawlerService implements OnModuleInit {
deadline: r.deadline, deadline: r.deadline,
detailUrl: r.detailUrl, detailUrl: r.detailUrl,
publishTime: r.publishTime || null, publishTime: r.publishTime || null,
category: categoryMap.get(r.noticeId) || '',
...(r.amount ? { amount: r.amount } : {}), ...(r.amount ? { amount: r.amount } : {}),
...(r.region ? { region: r.region } : {}),
...(r.regionDetail ? { regionDetail: r.regionDetail } : {}),
summary: `来源: ${r.source}\n公告ID: ${r.noticeId}\n链接: ${r.detailUrl}`, summary: `来源: ${r.source}\n公告ID: ${r.noticeId}\n链接: ${r.detailUrl}`,
}, },
create: { create: {
@ -156,20 +142,17 @@ export class CrawlerService implements OnModuleInit {
amount: r.amount || '待确认', amount: r.amount || '待确认',
status: '待复核', status: '待复核',
level: 'C', level: 'C',
category: categoryMap.get(r.noticeId) || '', category: '',
region: r.region || '', region: '',
regionDetail: r.regionDetail || '',
summary: `来源: ${r.source}\n公告ID: ${r.noticeId}\n链接: ${r.detailUrl}`, summary: `来源: ${r.source}\n公告ID: ${r.noticeId}\n链接: ${r.detailUrl}`,
}, },
}) })
successCount++ successCount++
} catch (e: unknown) { } catch {
this.logger.warn(`入库失败 [${r.noticeId}] ${r.title.slice(0, 30)}: ${(e as Error).message}`) // 其他异常跳过
} }
} }
this.logger.log(`入库成功 ${successCount} / ${validResults.length} 条`)
return this.prisma.crawlerConfig.update({ return this.prisma.crawlerConfig.update({
where: { id }, where: { id },
data: { data: {
@ -189,127 +172,52 @@ export class CrawlerService implements OnModuleInit {
} }
} }
private async resolveKeywords(config: { categories?: string | null; keywords?: string | null }): Promise<string[]> { private async runPowerChina(): Promise<FetchResult[]> {
if (config.categories) {
try {
const catNames: string[] = JSON.parse(config.categories)
if (catNames.length > 0) {
const cats = await this.prisma.productCategory.findMany({
where: { name: { in: catNames } },
})
const kwSet = new Set<string>()
for (const cat of cats) {
try {
const kws: string[] = JSON.parse(cat.keywords)
kws.forEach((k) => kwSet.add(k))
} catch { /* skip */ }
}
if (kwSet.size > 0) return Array.from(kwSet)
}
} catch { /* fallback */ }
}
if (config.keywords) {
try {
return JSON.parse(config.keywords)
} catch { /* skip */ }
}
return ['']
}
private async runPowerChina(config: { categories?: string | null; keywords?: string | null }): Promise<FetchResult[]> {
const scriptPath = path.join(TENDER_FETCHER_DIR, 'fetch-v1.2.js') const scriptPath = path.join(TENDER_FETCHER_DIR, 'fetch-v1.2.js')
if (!fs.existsSync(scriptPath)) { if (!fs.existsSync(scriptPath)) {
throw new Error('中电建抓取脚本不存在') throw new Error('中电建抓取脚本不存在')
} }
const keywords = await this.resolveKeywords(config) execSync(`node "${scriptPath}"`, {
const today = new Date().toISOString().split('T')[0]
const baseDir = path.join(process.env.HOME || '', 'desktop/招标公告', today)
const seen = new Set<string>()
const allResults: FetchResult[] = []
for (const kw of keywords) {
const kwFlag = kw ? ` --keyword "${kw}"` : ''
try {
this.logger.log(`执行关键词 [${kw || '(空)'}] 抓取脚本`)
execSync(`node "${scriptPath}"${kwFlag}`, {
encoding: 'utf-8', encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
timeout: 180000, timeout: 180000,
}) })
} catch (e: unknown) {
this.logger.warn(`关键词 [${kw || '(空)'}] 抓取失败: ${(e as { stderr?: string; message?: string }).stderr || (e as { message?: string }).message}`)
continue
}
// 每个关键词有独立的状态文件: downloaded_<keyword>.json const today = new Date().toISOString().split('T')[0]
const stateFileName = kw ? `downloaded_${kw}.json` : 'downloaded.json' const statePath = path.join(
const statePath = path.join(baseDir, stateFileName) process.env.HOME || '',
if (!fs.existsSync(statePath)) { 'desktop/招标公告',
this.logger.warn(`关键词 [${kw || '(空)'}] 状态文件不存在: ${statePath}`) today,
continue 'downloaded.json',
} )
if (!fs.existsSync(statePath)) return []
try {
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8')) const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
const downloaded = state.downloaded || {} const downloaded = state.downloaded || {}
this.logger.log(`关键词 [${kw || '(空)'}] 状态文件读取成功: ${Object.keys(downloaded).length} 条记录`) const results: FetchResult[] = []
const outputDir = path.dirname(statePath)
const pdfDir = path.join(outputDir, 'pdf')
for (const [key, v] of Object.entries(downloaded)) { for (const [key, v] of Object.entries(downloaded)) {
const info = v as { title?: string; skipped?: boolean; failed?: boolean; id?: string; deadline?: string; publishTime?: string; path?: string } const info = v as { title?: string; skipped?: boolean; failed?: boolean; id?: string; deadline?: string }
if (info.skipped || info.failed) continue if (info.skipped || info.failed) continue
const [systemId] = key.split('_') const [systemId] = key.split('_')
if (seen.has(systemId)) continue
seen.add(systemId)
const realId = info.id || systemId const realId = info.id || systemId
results.push({
let region = ''
let regionDetail = ''
if (info.path && fs.existsSync(pdfDir)) {
const pdfPath = path.join(pdfDir, path.basename(info.path))
if (fs.existsSync(pdfPath)) {
try {
const extractor = path.join(SCRIPTS_DIR, 'extract_address_from_pdf.py')
const title = info.title || ''
const cmd = `python3 "${extractor}" "${pdfPath}" "${title}"`
const out = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim()
const parsed = JSON.parse(out)
region = parsed.region || ''
regionDetail = parsed.regionDetail || ''
} catch {}
}
}
allResults.push({
source: '中电建', source: '中电建',
title: info.title || '', title: info.title || '',
organization: '中电建', organization: '中电建',
publishTime: info.publishTime || state.lastRun || '', publishTime: state.lastRun || '',
deadline: info.deadline || '', deadline: info.deadline || '',
noticeId: systemId, noticeId: systemId,
detailUrl: `https://bid.powerchina.cn/notice/detail?id=${realId}`, detailUrl: `https://bid.powerchina.cn/notice/detail?id=${realId}`,
region,
regionDetail,
}) })
} }
} catch (e: unknown) {
this.logger.error(`关键词 [${kw || '(空)'}] 状态文件解析失败: ${(e as Error).message}`) return results
continue
}
} }
this.logger.log(`抓取汇总: ${allResults.length} 条公告待入库`) private async runGenericScript(config: { type: string; sourceName?: string | null; scriptPath?: string | null }): Promise<FetchResult[]> {
return allResults
}
private async runGenericScript(config: { type: string; sourceName?: string | null; scriptPath?: string | null; categories?: string | null; keywords?: string | null }): Promise<FetchResult[]> {
const slug = config.type.replace(/^bid_/, '') const slug = config.type.replace(/^bid_/, '')
const scriptFile = config.scriptPath || `fetch-${slug}.py` const scriptFile = config.scriptPath || `fetch-${slug}.py`
const scriptPath = path.join(TENDER_FETCHER_DIR, scriptFile) const scriptPath = path.join(TENDER_FETCHER_DIR, scriptFile)
@ -320,60 +228,42 @@ export class CrawlerService implements OnModuleInit {
throw new Error(`抓取脚本不存在: ${scriptFile}`) throw new Error(`抓取脚本不存在: ${scriptFile}`)
} }
const keywords = await this.resolveKeywords(config)
const today = new Date().toISOString().split('T')[0]
const baseDir = path.join(process.env.HOME || '', 'desktop/招标公告', today)
const statePath = path.join(baseDir, jsonFile)
const isJs = scriptFile.endsWith('.js') const isJs = scriptFile.endsWith('.js')
const results: FetchResult[] = [] const cmd = isJs ? `node "${scriptPath}"` : `python3 "${scriptPath}"`
const seen = new Set<string>()
const todayStr = new Date().toISOString().substring(0, 10)
for (const kw of keywords) {
const kwFlag = kw ? ` --keyword "${kw}"` : ''
const cmd = isJs ? `node "${scriptPath}"${kwFlag}` : `python3 "${scriptPath}"${kwFlag}`
try {
execSync(cmd, { execSync(cmd, {
encoding: 'utf-8', encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
timeout: 180000, timeout: 180000,
}) })
} catch {
// 单个 keyword 失败不影响其他 keyword
continue
}
if (!fs.existsSync(statePath)) continue const today = new Date().toISOString().split('T')[0]
const statePath = path.join(
process.env.HOME || '',
'desktop/招标公告',
today,
jsonFile,
)
if (!fs.existsSync(statePath)) return []
try {
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8')) const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
const results: FetchResult[] = []
for (const [noticeId, v] of Object.entries(state)) { for (const [noticeId, v] of Object.entries(state)) {
const info = v as { title?: string; org?: string; publish_time?: string; detail_url?: string; id?: string; deadline?: string; amount?: string }
if (noticeId === 'lastRun') continue if (noticeId === 'lastRun') continue
const info = v as { title?: string; org?: string; publish_time?: string; detail_url?: string; id?: string; deadline?: string; amount?: string; region?: string; region_detail?: string; downloaded_at?: string } const leadAmount = info.amount || ''
// 只处理本次新增的记录(downloaded_at 包含今天日期),历史记录已入库无需重新处理
if (info.downloaded_at && !info.downloaded_at.startsWith(todayStr)) continue
const dedupeKey = info.id || noticeId
if (seen.has(dedupeKey)) continue
seen.add(dedupeKey)
results.push({ results.push({
source, source,
title: info.title || '', title: info.title || '',
organization: info.org || source, organization: info.org || source,
publishTime: info.publish_time || '', publishTime: info.publish_time || '',
deadline: info.deadline || '', deadline: info.deadline || '',
noticeId: dedupeKey, noticeId: info.id || noticeId,
detailUrl: info.detail_url || '', detailUrl: info.detail_url || '',
amount: info.amount || '', amount: leadAmount,
region: info.region || '',
regionDetail: info.region_detail || '',
}) })
} }
} catch {
continue
}
}
return results return results
} }

View File

@ -1,202 +0,0 @@
import { Injectable, Logger } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions'
const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7'
const MINIMAX_TIMEOUT_MS = 60000
const MAX_INPUT_CHARS = 4000
const SYSTEM_PROMPT = `你是一个招标公告信息提取助手。请从以下公告内容中提取关键商业要求,整理成简明要点。
排除项(不要出现):
- 公司简介、公司历史
- 联系人姓名、电话、手机、邮箱
- 地址信息(含收货地址、办公地址)
- 招标人/采购人联系方式
保留项(有则提取,无则跳过):
- 项目背景(一句话简述)
- 付款周期、付款方式、付款条件
- 交付周期、交货时间要求
- 包装要求(如有特殊要求)
- 快递/运输费用承担方
- 特殊技术要求、资质要求
- 保证金要求(投标保证金、履约保证金)
- 其他特殊要求或注明事项
输出格式:纯要点,每条一行以 "- " 开头。不要标题、分类头、解释文字。
每条要点控制在20字以内,最多输出6条。如果某类信息原文没提到,就跳过不写。`
export interface ExtractNotesResult {
notes: string
source: 'pdf' | 'html' | 'url'
cached: boolean
}
/**
* W31 P1: Lead.notes 提取服务
* 老 GUI `extract_notes.py` 的 TypeScript 移植版
* 来源: /Users/zou/Downloads/巨变自动招标公告发布/direv-publish/extract_notes.py
*
* 流程:
* 1. PDF → text (PyMuPDF) / HTML → text (cheerio) / URL → text (fetch)
* 2. text → MiniMax API (MiniMax-M2.7)
* 3. MiniMax 返回 6 条 "- " 开头的要点
* 4. 缓存到 Lead.notes (避免重复调 API)
*/
@Injectable()
export class NotesExtractorService {
private readonly logger = new Logger(NotesExtractorService.name)
constructor(private prisma: PrismaService) {}
/**
* 入口 - 提取 Lead 备注 6 条要点
* @param leadId Lead UUID
* @param sourcePath 本地 PDF/HTML 路径, 或 URL (http/https)
* @param forceRefresh 强制重新提取 (默认 false, 命中缓存)
*/
async extractForLead(
leadId: string,
sourcePath: string,
forceRefresh = false,
): Promise<ExtractNotesResult> {
const lead = await this.prisma.lead.findUnique({ where: { id: leadId } })
if (!lead) throw new Error(`Lead ${leadId} 不存在`)
// 命中缓存 (24 小时内, 不强制刷新)
if (!forceRefresh && lead.notes && lead.notesExtractedAt) {
const ageMs = Date.now() - new Date(lead.notesExtractedAt).getTime()
if (ageMs < 24 * 3600 * 1000) {
this.logger.log(`[notes] 命中缓存 leadId=${leadId}`)
return {
notes: lead.notes,
source: (lead.notesSource as 'pdf' | 'html' | 'url') || 'url',
cached: true,
}
}
}
// 1. 提取原始文本
const source = this.detectSource(sourcePath)
const rawText = await this.extractText(sourcePath, source)
if (!rawText || rawText.length < 20) {
throw new Error(`文件内容过短或无法提取 (${rawText.length} 字符): ${sourcePath}`)
}
this.logger.log(`[notes] leadId=${leadId} source=${source} textLen=${rawText.length}`)
// 2. 调 DeepSeek 提取
const apiKey = process.env.MINIMAX_API_KEY
if (!apiKey) {
throw new Error('MINIMAX_API_KEY 未配置 (.env 加 MINIMAX_API_KEY=sk-xxx)')
}
const notes = await this.callMinimax(rawText, apiKey)
this.logger.log(`[notes] 提取完成 ${notes.split('\n').length} 条`)
// 3. 存到 Lead.notes
await this.prisma.lead.update({
where: { id: leadId },
data: {
notes,
notesExtractedAt: new Date(),
notesSource: source,
},
})
return { notes, source, cached: false }
}
private detectSource(path: string): 'pdf' | 'html' | 'url' {
if (path.startsWith('http://') || path.startsWith('https://')) return 'url'
const lower = path.toLowerCase()
if (lower.endsWith('.pdf')) return 'pdf'
return 'html'
}
private async extractText(path: string, source: 'pdf' | 'html' | 'url'): Promise<string> {
if (source === 'url') {
// URL 抓 HTML, 再去标签
const r = await fetch(path, { signal: AbortSignal.timeout(30000) })
const html = await r.text()
return this.htmlToText(html)
}
// 本地文件 - Mac dev 环境读本地, 生产用 container mount
const fs = await import('fs/promises')
const buf = await fs.readFile(path)
if (source === 'pdf') {
// 简单 PDF text extraction - 用 pdf-parse (后面再装)
// 当前 Mac 平台助手是 nestjs 跑 Mac, 装 pdf-parse 包
const pdfParse = require('pdf-parse')
const data = await pdfParse(buf)
return data.text
}
// HTML
return this.htmlToText(buf.toString('utf-8'))
}
private htmlToText(html: string): string {
// 简单 strip tags - 比 cheerio 轻
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, ' ')
.replace(/<head\b[^<]*(?:(?!<\/head>)<[^<]*)*<\/head>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)))
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
.join('\n')
}
private async callMinimax(rawText: string, apiKey: string): Promise<string> {
const text = rawText.length > MAX_INPUT_CHARS
? rawText.slice(0, MAX_INPUT_CHARS) + '\n[内容过长已截断]'
: rawText
const r = await fetch(MINIMAX_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MINIMAX_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: text },
],
temperature: 0.1,
max_tokens: 500,
}),
signal: AbortSignal.timeout(MINIMAX_TIMEOUT_MS),
})
if (!r.ok) {
const errText = await r.text().catch(() => '')
throw new Error(`MiniMax API ${r.status}: ${errText.slice(0, 200)}`)
}
const data = await r.json() as { choices?: Array<{ message?: { content?: string } }> }
const content = data.choices?.[0]?.message?.content || ''
if (!content) throw new Error(`MiniMax 返回空: ${JSON.stringify(data).slice(0, 200)}`)
// 清理输出: 每条以 "- " 开头, 最多 6 条
const lines = content
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
.slice(0, 6)
return lines.join('\n')
}
}

View File

@ -14,8 +14,6 @@ interface MCPClientInstance {
export class IntegrationService implements OnModuleDestroy { export class IntegrationService implements OnModuleDestroy {
private readonly logger = new Logger(IntegrationService.name) private readonly logger = new Logger(IntegrationService.name)
private mcpClients: Map<string, MCPClientInstance> = new Map() private mcpClients: Map<string, MCPClientInstance> = new Map()
/** MCP session id 缓存 (per integration, MCP Streamable HTTP 协议要求) */
private mcpSessionIds: Map<string, string> = new Map()
private readonly encryptKey: Buffer private readonly encryptKey: Buffer
constructor(private prisma: PrismaService) { constructor(private prisma: PrismaService) {
@ -75,58 +73,6 @@ export class IntegrationService implements OnModuleDestroy {
return headers 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<Response> => {
// 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() { async onModuleDestroy() {
for (const [id, instance] of this.mcpClients) { for (const [id, instance] of this.mcpClients) {
try { try {
@ -136,7 +82,6 @@ export class IntegrationService implements OnModuleDestroy {
} }
} }
this.mcpClients.clear() this.mcpClients.clear()
this.mcpSessionIds.clear()
} }
async create(data: { async create(data: {
@ -217,8 +162,6 @@ export class IntegrationService implements OnModuleDestroy {
try { try {
await this.disconnect(id) await this.disconnect(id)
// 重连前清空旧 session id, 强制重新走 initialize
this.clearSession(id)
const client = new Client( const client = new Client(
{ name: 'platform-assistant', version: '1.0.0' }, { name: 'platform-assistant', version: '1.0.0' },
@ -231,19 +174,11 @@ export class IntegrationService implements OnModuleDestroy {
requestInit: { requestInit: {
headers: this.buildAuthHeaders(integration), headers: this.buildAuthHeaders(integration),
}, },
// 自定义 fetch: 自动管理 mcp-session-id (MCP Streamable HTTP 协议要求)
fetch: this.buildSessionAwareFetch(id),
} }
) )
await client.connect(transport) 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 toolsResult = await client.listTools()
const tools = toolsResult.tools || [] const tools = toolsResult.tools || []
@ -283,9 +218,7 @@ export class IntegrationService implements OnModuleDestroy {
}, },
}) })
this.logger.log( this.logger.log(`MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}`)
`MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}, session=${this.mcpSessionIds.get(id)?.slice(0, 8) || 'none'}`,
)
return { success: true, tools } return { success: true, tools }
} catch (e: any) { } catch (e: any) {
@ -308,8 +241,6 @@ export class IntegrationService implements OnModuleDestroy {
} }
this.mcpClients.delete(id) this.mcpClients.delete(id)
} }
// 断开时清空 session, 下次 connect 强制重新走 initialize
this.clearSession(id)
} }
/** /**
@ -329,7 +260,6 @@ export class IntegrationService implements OnModuleDestroy {
{ capabilities: {} } { capabilities: {} }
) )
// 测试连接也走 session-aware fetch (用临时 integration id "test")
transport = new StreamableHTTPClientTransport( transport = new StreamableHTTPClientTransport(
new URL(endpoint), new URL(endpoint),
{ {
@ -339,7 +269,6 @@ export class IntegrationService implements OnModuleDestroy {
'x-app-secret': appSecret, 'x-app-secret': appSecret,
}, },
}, },
fetch: this.buildSessionAwareFetch('test'),
} }
) )
@ -444,11 +373,11 @@ export class IntegrationService implements OnModuleDestroy {
* 解析 MCP tool 返回的 content 字段 * 解析 MCP tool 返回的 content 字段
* MCP 协议格式: { content: [{ type: 'text', text: '...' }], isError?: boolean } * MCP 协议格式: { content: [{ type: 'text', text: '...' }], isError?: boolean }
*/ */
private parseToolResult<T>(result: any): T { private parseToolResult<T>(result: any): T | null {
if (!result) throw new Error('MCP 工具返回结果为空') try {
if (!result) return null
if (result.isError) { if (result.isError) {
const errMsg = typeof result.content?.[0]?.text === 'string' ? result.content[0].text : 'Tool 调用返回错误' throw new Error(typeof result.content?.[0]?.text === 'string' ? result.content[0].text : 'Tool 调用返回错误')
throw new Error(errMsg)
} }
if (result.content && Array.isArray(result.content) && result.content.length > 0) { if (result.content && Array.isArray(result.content) && result.content.length > 0) {
const item = result.content[0] const item = result.content[0]
@ -461,6 +390,10 @@ export class IntegrationService implements OnModuleDestroy {
} }
} }
return result as T return result as T
} catch (e: any) {
this.logger.error(`解析 MCP tool 结果失败: ${e.message}`)
return null
}
} }
getClient(integrationId: string): Client | null { getClient(integrationId: string): Client | null {

View File

@ -1,31 +0,0 @@
import { Controller, Post, Param, Query, HttpException, HttpStatus } from '@nestjs/common'
import { NotesExtractorService } from '../crawler/notes-extractor.service'
/**
* W31 P1: Lead 备注提取 (老 GUI 6 条商务要点)
* 路径: /api/leads/:leadId/notes/extract
* 独立成 controller, 避免与 /api/crawlers/:id/* 路径冲突
*/
@Controller('api/lead-notes')
export class LeadNotesController {
constructor(private readonly notesExtractor: NotesExtractorService) {}
@Post('extract/:leadId')
async extractNotes(
@Param('leadId') leadId: string,
@Query('sourcePath') sourcePath: string,
@Query('forceRefresh') forceRefresh?: string,
) {
if (!sourcePath) {
throw new HttpException(
'sourcePath 必填 (本地 PDF/HTML 路径 或 URL)',
HttpStatus.BAD_REQUEST,
)
}
return this.notesExtractor.extractForLead(
leadId,
sourcePath,
forceRefresh === 'true',
)
}
}

View File

@ -14,9 +14,8 @@ export class LeadController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('category') category?: string, @Query('category') category?: string,
@Query('assignedTo') assignedTo?: string, @Query('assignedTo') assignedTo?: string,
@Query('timeRange') timeRange?: string,
) { ) {
return this.leadService.findAll({ status, level, search, star, source, category, assignedTo, timeRange }) return this.leadService.findAll({ status, level, search, star, source, category, assignedTo })
} }
@Get('stats') @Get('stats')
@ -55,8 +54,8 @@ export class LeadController {
} }
@Post(':id/publish') @Post(':id/publish')
publish(@Param('id') id: string, @Body() body?: { contactPhone?: string; contactName?: string; deliveryAddress?: string }) { publish(@Param('id') id: string) {
return this.leadService.publishToDirev(id, body) return this.leadService.publishToDirev(id)
} }
@Post('batch-publish') @Post('batch-publish')

View File

@ -1,18 +1,13 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module' import { PrismaModule } from '../prisma/prisma.module'
import { IntegrationModule } from '../integration/integration.module' import { IntegrationModule } from '../integration/integration.module'
import { CrawlerModule } from '../crawler/crawler.module'
import { ProductCategoryModule } from '../product-category/product-category.module'
import { LeadService } from './lead.service' import { LeadService } from './lead.service'
import { MaterialExtractorService } from './material-extractor.service'
import { TitleCompressorService } from './title-compressor.service'
import { LeadController } from './lead.controller' import { LeadController } from './lead.controller'
import { LeadNotesController } from './lead-notes.controller'
@Module({ @Module({
imports: [PrismaModule, IntegrationModule, CrawlerModule, ProductCategoryModule], imports: [PrismaModule, IntegrationModule],
controllers: [LeadController, LeadNotesController], controllers: [LeadController],
providers: [LeadService, MaterialExtractorService, TitleCompressorService], providers: [LeadService],
exports: [LeadService], exports: [LeadService],
}) })
export class LeadModule {} export class LeadModule {}

View File

@ -1,10 +1,6 @@
import { Injectable, NotFoundException, Logger, ServiceUnavailableException } from '@nestjs/common' import { Injectable, NotFoundException, Logger, ServiceUnavailableException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import { IntegrationService } from '../integration/integration.service' import { IntegrationService } from '../integration/integration.service'
import { ProductCategoryService } from '../product-category/product-category.service'
import { CategoryInferenceService } from '../product-category/category-inference.service'
import { MaterialExtractorService } from './material-extractor.service'
import { TitleCompressorService } from './title-compressor.service'
@Injectable() @Injectable()
export class LeadService { export class LeadService {
@ -13,13 +9,9 @@ export class LeadService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private integrationService: IntegrationService, private integrationService: IntegrationService,
private categoryService: ProductCategoryService,
private categoryInference: CategoryInferenceService,
private materialExtractor: MaterialExtractorService,
private titleCompressor: TitleCompressorService,
) {} ) {}
async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string; timeRange?: string }) { async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string }) {
const where: any = {} const where: any = {}
if (params?.status) where.status = params.status if (params?.status) where.status = params.status
if (params?.level) where.level = params.level if (params?.level) where.level = params.level
@ -36,40 +28,16 @@ export class LeadService {
else if (!isNaN(starVal)) where.star = starVal else if (!isNaN(starVal)) where.star = starVal
} }
if (params?.source) where.source = params.source if (params?.source) where.source = params.source
if (params?.category) { if (params?.category) where.category = params.category
where.category = params.category
}
if (params?.assignedTo) { if (params?.assignedTo) {
where.assignedTo = params.assignedTo === '未分配' ? null : params.assignedTo where.assignedTo = params.assignedTo === '未分配' ? null : params.assignedTo
} }
if (params?.timeRange && params.timeRange !== '全部时间') {
const now = new Date()
const pad = (n: number) => n.toString().padStart(2, '0')
const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
if (params.timeRange === '今天') {
where.publishTime = { startsWith: today }
} else if (params.timeRange === '昨天') {
const y = new Date(now)
y.setDate(y.getDate() - 1)
const yesterday = `${y.getFullYear()}-${pad(y.getMonth() + 1)}-${pad(y.getDate())}`
where.publishTime = { startsWith: yesterday }
} else if (params.timeRange === '本周') {
const day = now.getDay()
const diff = day === 0 ? 6 : day - 1 // Monday
const monday = new Date(now)
monday.setDate(now.getDate() - diff)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
const m = `${monday.getFullYear()}-${pad(monday.getMonth() + 1)}-${pad(monday.getDate())}`
const s = `${sunday.getFullYear()}-${pad(sunday.getMonth() + 1)}-${pad(sunday.getDate())}`
where.publishTime = { gte: m, lte: s + ' 23:59:59' }
} else if (params.timeRange === '本月') {
where.publishTime = { startsWith: `${now.getFullYear()}-${pad(now.getMonth() + 1)}` }
}
}
return this.prisma.lead.findMany({ return this.prisma.lead.findMany({
where, where,
orderBy: [{ publishTime: 'desc' }, { createdAt: 'desc' }], orderBy: [
{ level: 'asc' },
{ createdAt: 'desc' },
],
}) })
} }
@ -244,10 +212,7 @@ export class LeadService {
}) })
} }
async publishToDirev( async publishToDirev(id: string) {
id: string,
overrides?: { contactPhone?: string; contactName?: string; deliveryAddress?: string },
) {
const lead = await this.findOne(id) const lead = await this.findOne(id)
const now = new Date() const now = new Date()
@ -261,12 +226,8 @@ export class LeadService {
const publishArgs: any = { const publishArgs: any = {
title: lead.title || '平台助手发布询价', title: lead.title || '平台助手发布询价',
// W31 P1: 优先用 notes (老 GUI 6 条商务要点) 作为 description description: lead.summary || lead.title || '',
// 备选 summary (来源/公告ID/链接 3 行), 最后 fallback title
description: lead.notes || lead.summary || lead.title || '',
qty: lead.quantity || 1, qty: lead.quantity || 1,
// W31 P1: 同步把 notes 传过去 (B2B note 字段会用它, 商家报价时看到)
notes: lead.notes || undefined,
} }
if (lead.attachments) { if (lead.attachments) {
@ -289,22 +250,17 @@ export class LeadService {
d.setDate(d.getDate() + 30) d.setDate(d.getDate() + 30)
publishArgs.deliveryDate = d.toISOString().slice(0, 10) publishArgs.deliveryDate = d.toISOString().slice(0, 10)
} }
if (overrides?.deliveryAddress) publishArgs.deliveryAddress = overrides.deliveryAddress if (lead.regionDetail) publishArgs.shippingAddress = lead.regionDetail
else if (lead.regionDetail) publishArgs.deliveryAddress = lead.regionDetail else if (lead.region) publishArgs.shippingAddress = lead.region
else if (lead.region) publishArgs.deliveryAddress = lead.region
// 联系人信息(弹窗用户可编辑覆盖,否则取线索数据,最后用占位) if (lead.contact) publishArgs.contactName = lead.contact
publishArgs.contactName = overrides?.contactName || lead.contact || '待补充' if (lead.phone) publishArgs.contactPhone = lead.phone
publishArgs.contactPhone = overrides?.contactPhone || lead.phone || '待补充'
// 企业级业务字段(按 MCP publish_rfq 对接文档 4.2 节字段类型和值)
publishArgs.inquiryParty = '郑州巨变网络科技有限公司' publishArgs.inquiryParty = '郑州巨变网络科技有限公司'
publishArgs.freightFlag = 1 // 1=含运费(int,不要传字符串) publishArgs.freightFlag = '含运费'
publishArgs.procurementType = 1 // 1=公开采购 publishArgs.procurementType = '定量采购'
publishArgs.invoiceType = 2 // 2=增值税专票 publishArgs.invoiceType = '增值税专票'
// paymentMethod 不传,后端会用企业默认配置(传了"23"反而会报"企业字典找不到") publishArgs.paymentMethod = '货到付款'
publishArgs.quotationDemand = 1 // 1=需要报价(新增,文档要求)
publishArgs.validDays = 7 // 报价有效期天数(新增,文档要求)
// Auto-fill enterprise identity from local Integration config // Auto-fill enterprise identity from local Integration config
if (b2bIntegration.companyUserId) { if (b2bIntegration.companyUserId) {
@ -313,38 +269,6 @@ export class LeadService {
publishArgs.enterpriseName = `${b2bIntegration.name}公司` publishArgs.enterpriseName = `${b2bIntegration.name}公司`
} }
// 预校验: B2B 平台字段长度限制 (tb_inquiry.title varchar(50))
// 超长标题先 AI 压缩(保留 公司名+物料+采购类型),压缩失败自动硬截断兜底
const TITLE_MAX_LEN = 50
if (publishArgs.title && String(publishArgs.title).length > TITLE_MAX_LEN) {
publishArgs.title = await this.titleCompressor.compress(
String(publishArgs.title),
TITLE_MAX_LEN,
)
}
if (String(publishArgs.title).length > TITLE_MAX_LEN) {
const titleLen = String(publishArgs.title).length
throw new ServiceUnavailableException(
[
`发布失败: 询价标题超过 B2B 平台限制(最多 ${TITLE_MAX_LEN} 字)。`,
`当前标题长度: ${titleLen} 字`,
`建议: ① 把标题精简到 ${TITLE_MAX_LEN} 字以内后再发布; ② 或联系 B2B 平台把 tb_inquiry.title 字段改为 varchar(200) 永久解决。`,
`线索标题: ${String(publishArgs.title).slice(0, 40)}${titleLen > 40 ? '...' : ''}`,
].join('\n'),
)
}
// 实时提取物料清单(detailUrl 正文 → 标题 → 兜底),传给产业平台 items 字段
try {
const items = await this.materialExtractor.extractItems(lead)
if (items.length > 0) {
publishArgs.items = items
this.logger.log(`[发布] 物料清单提取完成 leadId=${id} ${items.length} 行`)
}
} catch (e: any) {
this.logger.warn(`[发布] 物料清单提取失败(不阻断发布) leadId=${id}: ${e.message}`)
}
const result = await this.integrationService.executeTool<any>( const result = await this.integrationService.executeTool<any>(
b2bIntegration.id, b2bIntegration.id,
'publish_rfq', 'publish_rfq',
@ -368,9 +292,9 @@ export class LeadService {
const payload = data.data || data const payload = data.data || data
const rfqId = payload.rfqId || payload.id || null const rfqId = payload.rfqId || payload.id || null
if (!rfqId) { if (!rfqId) {
// 识别常见错误模式,给出明确提示 throw new ServiceUnavailableException(
const diagnosis = this.diagnosePublishError(lead, payload) `发布失败: B2B 平台返回了响应但未包含询价 ID。响应: ${JSON.stringify(payload).slice(0, 200)}`,
throw new ServiceUnavailableException(diagnosis) )
} }
const inquiryNo = payload.orderId || rfqId const inquiryNo = payload.orderId || rfqId
const direvUrl = `${b2bIntegration.endpoint}/inquiry/${rfqId}` const direvUrl = `${b2bIntegration.endpoint}/inquiry/${rfqId}`
@ -480,54 +404,4 @@ export class LeadService {
return [] return []
} }
} }
/**
* 发布失败的诊断提示 — 把产业平台原始错误翻译成明确指引
* 输入: lead(知道当前 title/quantity 等) + payload(产业平台响应)
* 输出: 给前端展示的友好错误信息
*/
private diagnosePublishError(
lead: {
title?: string | null
quantity?: number | null
region?: string | null
deadline?: string | null
},
payload: any,
): string {
// 把 payload 序列化用于关键词匹配
const raw = JSON.stringify(payload || {}).slice(0, 500)
const msg = String(payload?.msg || payload?.message || '')
// 1. 标题超长(产业平台 tb_inquiry.title 是 varchar(50))
if (
/Data truncation|Data too long.*title|column 'title'.*too long/i.test(raw + ' ' + msg)
) {
const titleLen = (lead.title || '').length
return [
'发布失败: 询价标题超过 B2B 平台限制(最多 50 字)。',
`当前线索标题长度: ${titleLen} 字`,
`建议: ① 把标题精简到 50 字以内; ② 或联系 B2B 平台把 tb_inquiry.title 字段改为 varchar(200)。`,
`线索标题: ${(lead.title || '').slice(0, 30)}${titleLen > 30 ? '...' : ''}`,
].join('\n')
}
// 2. 数量字段异常
if (/Data too long.*qty|column 'qty'|column 'quantity'/i.test(raw + ' ' + msg)) {
return `发布失败: 询价数量字段超过 B2B 平台限制。当前 quantity=${lead.quantity}。建议改成合理数字(≤1000)。`
}
// 3. 企业/用户身份未通过
if (/enterprise|user.*id|companyUserId|身份|权限/i.test(msg)) {
return `发布失败: B2B 平台校验企业身份未通过(${msg})。请检查运营管理 → 系统配置 → MCP 对接配置的连接状态(状态灯应为绿色"已连接")。`
}
// 4. 必填字段缺失
if (/required|不能为空|not.*null|缺失/i.test(msg)) {
return `发布失败: B2B 平台提示必填字段缺失(${msg})。线索当前数据:quantity=${lead.quantity ?? '空'} region=${lead.region || '空'} deadline=${lead.deadline || '空'}。请补充完整后再发布。`
}
// 5. 默认:展示原始错误
return `发布失败: B2B 平台返回了响应但未包含询价 ID。原因: ${msg || '未知错误'}。响应: ${raw}`
}
} }

View File

@ -1,280 +0,0 @@
import { Injectable, Logger } from '@nestjs/common'
const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions'
const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7'
const DETAIL_FETCH_TIMEOUT_MS = 10_000 // detailUrl 正文抓取超时
const TEXT_EXTRACT_TIMEOUT_MS = 30_000 // 正文物料提取超时
const TITLE_EXTRACT_TIMEOUT_MS = 15_000 // 标题物资名提取超时
const MAX_INPUT_CHARS = 4000
export interface MaterialItem {
productName: string
quantity: number
unit: string
specification?: string
brand?: string
note?: string
}
const TEXT_SYSTEM_PROMPT = `你是招标公告物资清单提取助手。从以下公告内容中提取物资/物料明细。
输出格式:纯 JSON 数组,不要解释,不要 markdown 代码块,不要 <think> 标签。
每项格式:{"productName":"物资名称","quantity":数字,"unit":"单位","specification":"规格型号"}
规则:
- productName 必填,物资的具体名称(如:电力电缆、水泵、变压器)
- quantity 提取到的数量,没有就填 1
- unit 计量单位(件/米/吨/台/批等),没有就填"批"
- specification 规格型号,没有就填空字符串
- 最多输出 20 行
- 如果内容里没有具体物资清单,返回 []`
const TITLE_SYSTEM_PROMPT = `你是物资名称提取助手。从招标公告标题中提取涉及的物资名称。
输出格式:纯 JSON 数组,不要解释,不要 markdown 代码块,不要 <think> 标签。
每项格式:{"productName":"物资名称"}
规则:
- 只提取具体的物资/设备名称(如:油浸式电力变压器、电压互感器、水泵、逆变器)
- 去掉"采购""招标""项目""变更公告"等噪声词
- 如果标题里有多个物资(用顿号/逗号分隔),每个都提取
- 如果标题只提到一类物资,提取 1 条
- 如果完全无法判断物资名称,返回 []`
/**
* 物料清单提取服务
*
* 发布到产业平台时实时提取,降级链:
* 1. 抓 detailUrl 正文 → AI 提取多行物料
* 2. 抓不到 → AI 从标题提取物资名 (quantity=1, unit="批")
* 3. AI 也失败 → 用 lead.title 兜底
*/
@Injectable()
export class MaterialExtractorService {
private readonly logger = new Logger(MaterialExtractorService.name)
/**
* 主入口 - 从 Lead 提取物料清单
* 全程降级,任何异常都返回至少 1 条兜底数据,不阻断发布
*/
async extractItems(lead: { id: string; title: string; detailUrl?: string | null }): Promise<MaterialItem[]> {
const apiKey = process.env.MINIMAX_API_KEY
if (!apiKey) {
this.logger.warn(`[物料提取] MINIMAX_API_KEY 未配置,走标题兜底 leadId=${lead.id}`)
return this.fallbackWithTitle(lead.title)
}
// 第 1 步:尝试抓 detailUrl 正文
if (lead.detailUrl) {
try {
const text = await this.fetchDetailText(lead.detailUrl)
if (text.length > 100) {
this.logger.log(`[物料提取] 抓到正文 leadId=${lead.id} textLen=${text.length}`)
const items = await this.extractFromText(text, apiKey)
if (items.length > 0) {
this.logger.log(`[物料提取] 正文提取成功 leadId=${lead.id} ${items.length} 行`)
return items
}
this.logger.log(`[物料提取] 正文提取为空,降级标题 leadId=${lead.id}`)
} else {
this.logger.log(`[物料提取] 正文过短(${text.length}字),降级标题 leadId=${lead.id}`)
}
} catch (e: any) {
this.logger.warn(`[物料提取] 抓取正文失败 leadId=${lead.id}: ${e.message}`)
}
}
// 第 2 步:从标题提取
try {
const items = await this.extractFromTitle(lead.title, apiKey)
if (items.length > 0) {
this.logger.log(`[物料提取] 标题提取成功 leadId=${lead.id} ${items.length} 行`)
return items
}
} catch (e: any) {
this.logger.warn(`[物料提取] 标题提取失败 leadId=${lead.id}: ${e.message}`)
}
// 第 3 步:兜底
this.logger.log(`[物料提取] 走标题兜底 leadId=${lead.id}`)
return this.fallbackWithTitle(lead.title)
}
/** 抓 detailUrl HTML 并转纯文本,失败返回空串 */
private async fetchDetailText(detailUrl: string): Promise<string> {
const r = await fetch(detailUrl, {
signal: AbortSignal.timeout(DETAIL_FETCH_TIMEOUT_MS),
headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' },
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const html = await r.text()
return this.htmlToText(html)
}
/** 从正文提取物料行 */
private async extractFromText(text: string, apiKey: string): Promise<MaterialItem[]> {
const truncated = text.length > MAX_INPUT_CHARS
? text.slice(0, MAX_INPUT_CHARS) + '\n[内容过长已截断]'
: text
const content = await this.callMinimax(
TEXT_SYSTEM_PROMPT,
truncated,
apiKey,
TEXT_EXTRACT_TIMEOUT_MS,
2000,
)
return this.parseItems(content)
}
/** 从标题提取物资名,自动补 quantity=1 unit=批 */
private async extractFromTitle(title: string, apiKey: string): Promise<MaterialItem[]> {
const content = await this.callMinimax(
TITLE_SYSTEM_PROMPT,
title,
apiKey,
TITLE_EXTRACT_TIMEOUT_MS,
800,
)
// 解析 [{productName: "xxx"}, ...]
const raw = this.extractJsonArray(content)
if (!raw) return []
let parsed: any[]
try {
parsed = JSON.parse(raw)
} catch {
return []
}
return parsed
.filter((item: any) => item && typeof item.productName === 'string' && item.productName.trim())
.map((item: any) => ({
productName: String(item.productName).trim(),
quantity: 1,
unit: '批',
specification: '',
}))
}
/** 调 MiniMax chat completion,返回 choices[0].message.content */
private async callMinimax(
systemPrompt: string,
userContent: string,
apiKey: string,
timeoutMs: number,
maxTokens: number,
): Promise<string> {
const r = await fetch(MINIMAX_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MINIMAX_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
temperature: 0,
max_tokens: maxTokens,
}),
signal: AbortSignal.timeout(timeoutMs),
})
if (!r.ok) {
const errText = await r.text().catch(() => '')
throw new Error(`MiniMax API ${r.status}: ${errText.slice(0, 200)}`)
}
const data = (await r.json()) as { choices?: Array<{ message?: { content?: string } }> }
const content = data.choices?.[0]?.message?.content || ''
if (!content) throw new Error(`MiniMax 返回空: ${JSON.stringify(data).slice(0, 200)}`)
return content
}
/** 从 MiniMax 响应解析物料数组,带脏数据清洗 + 字段校验 */
private parseItems(content: string): MaterialItem[] {
const jsonStr = this.extractJsonArray(content)
if (!jsonStr) return []
let parsed: any[]
try {
parsed = JSON.parse(jsonStr)
} catch {
return []
}
if (!Array.isArray(parsed)) return []
const items: MaterialItem[] = []
for (const raw of parsed) {
if (!raw || typeof raw !== 'object') continue
const productName = typeof raw.productName === 'string' ? raw.productName.trim() : ''
if (!productName) continue
// quantity 必须是正数
let quantity = 1
if (typeof raw.quantity === 'number' && raw.quantity > 0) {
quantity = raw.quantity
} else if (typeof raw.quantity === 'string') {
const n = parseFloat(raw.quantity)
if (!isNaN(n) && n > 0) quantity = n
}
const unit = typeof raw.unit === 'string' && raw.unit.trim() ? raw.unit.trim() : '批'
const specification = typeof raw.specification === 'string' ? raw.specification.trim() : ''
items.push({ productName, quantity, unit, specification })
if (items.length >= 20) break
}
return items
}
/**
* 从模型输出中提取 JSON 数组字符串
* 处理 <think> 标签、markdown 代码块、前后缀解释文字
*/
private extractJsonArray(content: string): string | null {
// 1. 剥 <think>...</think> 和 <think> 开放标签
let s = content
.replace(/<\/?think>/g, '')
.replace(/\*\*/g, '')
.trim()
// 2. 尝试从 markdown 代码块提取 ```json ... ``` 或 ``` ... ```
const codeBlock = s.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (codeBlock) s = codeBlock[1].trim()
// 3. 提取第一个 [...] JSON 数组
const match = s.match(/\[[\s\S]*\]/)
return match ? match[0] : null
}
/** 兜底:用标题当物资名 */
private fallbackWithTitle(title: string): MaterialItem[] {
return [{ productName: title || '未命名物资', quantity: 1, unit: '批', specification: '' }]
}
/** HTML 转纯文本(复用 notes-extractor 的清洗逻辑) */
private htmlToText(html: string): string {
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, ' ')
.replace(/<head\b[^<]*(?:(?!<\/head>)<[^<]*)*<\/head>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)))
.split('\n')
.map(l => l.trim())
.filter(l => l.length > 0)
.join('\n')
}
}

View File

@ -1,88 +0,0 @@
import { Injectable, Logger } from '@nestjs/common'
const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions'
const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7'
const MINIMAX_TIMEOUT_MS = 30000
// 压缩规则: 只保留 公司名(简称) + 物料 + 采购类型
// 给 B2B 平台 50 字上限留 5 字余量, 避免平台侧校验边界
const MAX_COMPRESS_LEN = 45
// M2.7 推理模型会把思考过程输出在正文里且风格多变, 行级正则过滤不可靠,
// 改为要求 JSON 结构化输出, 解析不到就硬截断兜底
const PROMPT = `把采购公告标题压缩成询价标题, 只保留: 采购公司名称(简称)、物料、采购类型。
要求:
1. 压缩后不超过 45 个汉字
2. 只输出 JSON: {"title": "压缩后的标题"}
3. 不要输出任何其他内容、解释、序号、markdown
示例:
输入: 中国电建集团港航建设有限公司 定襄县农村饮水管网改造工程第1标段项目管材采购公开询比采购公告
输出: {"title": "电建港航定襄县管网改造一标管材询比采购"}`
@Injectable()
export class TitleCompressorService {
private readonly logger = new Logger(TitleCompressorService.name)
async compress(title: string, maxLength: number): Promise<string> {
if (!title || title.length <= maxLength) return title
const apiKey = process.env.MINIMAX_API_KEY
if (!apiKey) {
this.logger.warn('MINIMAX_API_KEY 未配置, 标题硬截断')
return title.slice(0, maxLength)
}
try {
const raw = await this.callMinimax(title, apiKey, Math.min(maxLength, MAX_COMPRESS_LEN))
const parsedTitle = this.extractJsonTitle(raw)
if (!parsedTitle) {
this.logger.warn(`AI 压缩无有效输出, 硬截断: ${raw.slice(0, 100)}`)
return title.slice(0, maxLength)
}
const result = parsedTitle.length > maxLength ? parsedTitle.slice(0, maxLength) : parsedTitle
this.logger.log(`标题 AI 压缩 (${title.length}字→${result.length}字): "${result}"`)
return result
} catch (e: any) {
this.logger.warn(`AI 压缩失败, 硬截断: ${e.message}`)
return title.slice(0, maxLength)
}
}
/** M2.7 常在 JSON 前后夹杂思考文本, 用正则提取第一个合法的 {"title":"..."} 片段 */
private extractJsonTitle(raw: string): string {
const m = raw.match(/\{"title"\s*:\s*"([^"]+)"\}/)
if (m) return m[1]
const loose = raw.match(/"title"\s*:\s*"([^"]+)"/)
return loose ? loose[1] : ''
}
private async callMinimax(title: string, apiKey: string, maxLen: number): Promise<string> {
const r = await fetch(MINIMAX_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MINIMAX_MODEL,
messages: [
{ role: 'system', content: PROMPT.replace('45', String(maxLen)) },
{ role: 'user', content: title },
],
temperature: 0,
max_tokens: 800, // M2.7 推理模型会输出明文思考, 400 会被思考过程耗尽导致答案截断 (AGENTS.md: 标题提取用 800)
}),
signal: AbortSignal.timeout(MINIMAX_TIMEOUT_MS),
})
if (!r.ok) {
const errText = await r.text().catch(() => '')
throw new Error(`MiniMax API ${r.status}: ${errText.slice(0, 200)}`)
}
const data = (await r.json()) as { choices?: Array<{ message?: { content?: string } }> }
return data.choices?.[0]?.message?.content || ''
}
}

View File

@ -1,231 +0,0 @@
import { Injectable, Logger } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { isGarbageKeyword, ProductCategoryService } from '../product-category/product-category.service'
const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions'
const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7'
const MINIMAX_TIMEOUT_MS = 30000
const NOISE_PREFIX = /^(让我|用户|我需要|好的|现在|首先|分析|根据|对于|这条|这个|考虑|我们|这里|注意|提示|重要|关键|总结|综上|该|其|它|他们|这些|那些|由于|因为|所以|因此|需要|可能|应该|可以|建议|希望|要求|必须)/
const PROJECT_WORDS = /采购|项目|公司|工程|设备|材料|公告|招标|标段|框架|年度/
function isValidCategoryName(raw: string): boolean {
if (!raw) return false
const s = raw.trim()
if (s.length === 0 || s.length > 6) return false
if (/[*\->→]/.test(s)) return false
if (/[,。:;!?、]/.test(s)) return false
if (/[A-Za-z]{2,}/.test(s)) return false
if (!/^[一-龥]+$/.test(s)) return false
if (NOISE_PREFIX.test(s)) return false
if (PROJECT_WORDS.test(s)) return false
return true
}
function sanitizeOne(raw: string): string {
if (!raw) return ''
let s = String(raw)
s = s.replace(/<\/?think>/g, '').trim()
s = s.replace(/\*\*/g, '').trim()
const lines = s.split('\n').map((l) => l.trim()).filter((l) => l)
for (const line of lines) {
if (NOISE_PREFIX.test(line)) continue
if (line.length > 20) continue
if (/[,。:;!?、]/.test(line)) continue
const cleaned = line.replace(/^[\d]+[\.、::]\s*/, '').trim()
if (!cleaned) continue
if (cleaned.includes(' - ') || cleaned.includes(' → ')) {
const parts = cleaned.split(/\s*(?:-|→)\s*/).map((p) => p.trim()).filter((p) => p)
for (const p of parts) {
if (isValidCategoryName(p)) return p
}
continue
}
if (isValidCategoryName(cleaned)) return cleaned
const m = cleaned.match(/([\u4e00-\u9fa5]{2,6})/)
if (m && isValidCategoryName(m[1])) return m[1]
}
return ''
}
const SYSTEM_RULES = `你是建材工业品分类助手。任务:把采购公告标题分类到一个品类名。
【绝对禁止】输出以下任何内容:
1. 思考/解释/前缀文字(如"让我分析"、"根据"、"属于"、"用户要求"等)
2. Markdown 符号:**加粗**、*斜体*
3. 中文标点:,。:;、!?
4. 英文标点:-、->
5. 项目标题/摘要/长描述(>8 字符)
6. 多个候选品类并列(如"钢材或管材")
7. think / 思考过程
【必须】输出:
- 仅 2-4 个中文字的品类名
- 只能匹配"已有品类",没有匹配返回新品类`
@Injectable()
export class CategoryInferenceService {
private readonly logger = new Logger(CategoryInferenceService.name)
constructor(
private prisma: PrismaService,
private categoryService: ProductCategoryService,
) {}
async infer(title: string, summary?: string | null): Promise<string> {
if (!title) return ''
const keywordResult = await this.categoryService.matchByKeywords(title)
if (keywordResult) return keywordResult.name
const apiKey = process.env.MINIMAX_API_KEY
if (!apiKey) {
this.logger.warn('MINIMAX_API_KEY 未配置, 无法 AI 分类')
return ''
}
try {
const aiCategory = sanitizeOne(await this.callMinimax(title, summary, apiKey))
if (aiCategory) {
const keyword = this.extractMainKeyword(title)
if (keyword) {
await this.categoryService.addKeywordToCategory(aiCategory, keyword)
}
this.logger.log(`AI 分类: "${title.slice(0, 40)}..." → ${aiCategory}`)
return aiCategory
}
} catch (e) {
this.logger.warn(`AI 分类失败: ${e.message}`)
}
return ''
}
async batchInfer(leads: Array<{ id: string; title: string; summary?: string | null }>): Promise<Map<string, string>> {
const result = new Map<string, string>()
const needsInference = []
for (const lead of leads) {
if (!lead.title) { result.set(lead.id, ''); continue }
const keywordResult = await this.categoryService.matchByKeywords(lead.title)
if (keywordResult) {
result.set(lead.id, keywordResult.name)
} else {
needsInference.push(lead)
}
}
if (needsInference.length === 0) return result
const apiKey = process.env.MINIMAX_API_KEY
if (!apiKey) {
for (const lead of needsInference) result.set(lead.id, '')
return result
}
const categories = await this.categoryService.findAll()
const catNames = categories.map((c) => c.name).join('、')
const systemPrompt = `${SYSTEM_RULES}\n\n已有品类:${catNames}`
const BATCH_SIZE = 20
for (let i = 0; i < needsInference.length; i += BATCH_SIZE) {
const batch = needsInference.slice(i, i + BATCH_SIZE)
const items = batch.map((l, idx) => `${idx + 1}. ${l.title}`).join('\n')
try {
const r = await fetch(MINIMAX_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MINIMAX_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: items },
],
temperature: 0,
max_tokens: 800,
}),
signal: AbortSignal.timeout(MINIMAX_TIMEOUT_MS),
})
if (!r.ok) {
const errText = await r.text().catch(() => '')
this.logger.warn(`MiniMax batch API ${r.status}: ${errText.slice(0, 200)}`)
for (const lead of batch) result.set(lead.id, '')
continue
}
const data = await r.json() as { choices?: Array<{ message?: { content?: string } }> }
const content = data.choices?.[0]?.message?.content || ''
const lines = content.split('\n').map((l) => l.trim()).filter((l) => l)
for (let j = 0; j < batch.length; j++) {
const catName = sanitizeOne(lines[j] || '')
if (catName) {
result.set(batch[j].id, catName)
const keyword = this.extractMainKeyword(batch[j].title)
if (keyword) {
await this.categoryService.addKeywordToCategory(catName, keyword)
}
} else {
result.set(batch[j].id, '')
}
}
} catch (e) {
this.logger.warn(`AI batch 分类失败: ${e.message}`)
for (const lead of batch) result.set(lead.id, '')
}
}
return result
}
private async callMinimax(title: string, summary: string | null | undefined, apiKey: string): Promise<string> {
const existingCats = (await this.categoryService.findAll()).map((c) => c.name).join('、')
const userContent = `标题: ${title}\n${summary ? `摘要: ${summary.slice(0, 500)}` : ''}`
const r = await fetch(MINIMAX_ENDPOINT, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: MINIMAX_MODEL,
messages: [
{ role: 'system', content: `${SYSTEM_RULES}\n\n已有品类:${existingCats}` },
{ role: 'user', content: userContent },
],
temperature: 0,
max_tokens: 60,
}),
signal: AbortSignal.timeout(MINIMAX_TIMEOUT_MS),
})
if (!r.ok) {
const errText = await r.text().catch(() => '')
throw new Error(`MiniMax API ${r.status}: ${errText.slice(0, 200)}`)
}
const data = await r.json() as { choices?: Array<{ message?: { content?: string } }> }
return data.choices?.[0]?.message?.content || ''
}
private extractMainKeyword(title: string): string {
// 取最后一个"采购"前面的 2-6 个中文字(更靠近实际物料名)
let lastMatch = ''
const regex = /([\u4e00-\u9fa5]{2,6})采购/g
let match: RegExpExecArray | null
while ((match = regex.exec(title)) !== null) {
lastMatch = match[1]
}
if (!lastMatch) return ''
// 跳过公司名/组织机构名
if (isGarbageKeyword(lastMatch)) return ''
return lastMatch
}
}

View File

@ -1,27 +0,0 @@
import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'
import { ProductCategoryService } from './product-category.service'
@Controller('api/categories')
export class ProductCategoryController {
constructor(private readonly categoryService: ProductCategoryService) {}
@Get()
findAll() {
return this.categoryService.findAll()
}
@Post()
create(@Body() body: { name: string; keywords: string[] }) {
return this.categoryService.create(body.name, body.keywords)
}
@Put(':id')
update(@Param('id') id: string, @Body() body: { name: string; keywords: string[] }) {
return this.categoryService.update(id, body.name, body.keywords)
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.categoryService.remove(id)
}
}

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module'
import { ProductCategoryService } from './product-category.service'
import { ProductCategoryController } from './product-category.controller'
import { CategoryInferenceService } from './category-inference.service'
@Module({
imports: [PrismaModule],
providers: [ProductCategoryService, CategoryInferenceService],
controllers: [ProductCategoryController],
exports: [ProductCategoryService, CategoryInferenceService],
})
export class ProductCategoryModule {}

View File

@ -1,83 +0,0 @@
import { Injectable, Logger } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
/** 判断是否是公司名/组织机构名等垃圾关键词,不应参与关键词匹配或反哺 */
export function isGarbageKeyword(kw: string): boolean {
if (/(?:公司|有限|集团|工程局|项目部)$/.test(kw)) return true
if (/^(?:中国电建|新疆)/.test(kw)) return true
const genericTerms = ['公开竞价', '公开招标', '竞争性谈判', '询价采购', '邀请招标']
if (genericTerms.includes(kw)) return true
return false
}
export interface CategoryMatch {
name: string
matchedBy: 'keyword' | 'ai' | null
}
@Injectable()
export class ProductCategoryService {
private readonly logger = new Logger(ProductCategoryService.name)
constructor(private prisma: PrismaService) {}
async findAll() {
return this.prisma.productCategory.findMany({ orderBy: { name: 'asc' } })
}
async create(name: string, keywords: string[]) {
return this.prisma.productCategory.create({
data: { name, keywords: JSON.stringify(keywords) },
})
}
async update(id: string, name: string, keywords: string[]) {
return this.prisma.productCategory.update({
where: { id },
data: { name, keywords: JSON.stringify(keywords) },
})
}
async remove(id: string) {
return this.prisma.productCategory.delete({ where: { id } })
}
async matchByKeywords(title: string): Promise<CategoryMatch | null> {
const categories = await this.prisma.productCategory.findMany()
for (const cat of categories) {
const keywords: string[] = JSON.parse(cat.keywords)
for (const kw of keywords) {
if (isGarbageKeyword(kw)) continue
if (title.includes(kw)) {
return { name: cat.name, matchedBy: 'keyword' }
}
}
}
return null
}
async addKeywordToCategory(categoryName: string, keyword: string) {
if (!keyword || isGarbageKeyword(keyword)) return
const cat = await this.prisma.productCategory.findUnique({ where: { name: categoryName } })
if (!cat) {
await this.prisma.productCategory.create({
data: {
name: categoryName,
keywords: JSON.stringify([keyword]),
isAIGenerated: true,
},
})
this.logger.log(`AI 新建品类: ${categoryName} (关键词: ${keyword})`)
return
}
const keywords: string[] = JSON.parse(cat.keywords)
if (!keywords.includes(keyword)) {
keywords.push(keyword)
await this.prisma.productCategory.update({
where: { id: cat.id },
data: { keywords: JSON.stringify(keywords) },
})
this.logger.log(`AI 补充品类 ${categoryName} 关键词: ${keyword}`)
}
}
}

View File

@ -1,10 +1,8 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ProductController } from './product.controller' import { ProductController } from './product.controller'
import { ProductService } from './product.service' import { ProductService } from './product.service'
import { IntegrationModule } from '../integration/integration.module'
@Module({ @Module({
imports: [IntegrationModule],
controllers: [ProductController], controllers: [ProductController],
providers: [ProductService], providers: [ProductService],
exports: [ProductService], exports: [ProductService],

View File

@ -1,15 +1,9 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import { IntegrationService } from '../integration/integration.service'
@Injectable() @Injectable()
export class ProductService { export class ProductService {
private readonly logger = new Logger(ProductService.name) constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private integrationService: IntegrationService,
) {}
async findAll() { async findAll() {
return this.prisma.product.findMany({ orderBy: { createdAt: 'desc' } }) return this.prisma.product.findMany({ orderBy: { createdAt: 'desc' } })
@ -42,84 +36,10 @@ export class ProductService {
} }
async batchPublish(ids: string[]) { async batchPublish(ids: string[]) {
const products = await this.prisma.product.findMany({ return this.prisma.product.updateMany({
where: { id: { in: ids } },
})
if (products.length === 0) return { imported: 0, errors: [], ids: [] }
const b2bIntegration = await this.integrationService.findActiveMCP()
if (!b2bIntegration) {
throw new ServiceUnavailableException('未配置 B2B 平台 MCP 集成,请在系统配置中添加')
}
const CATEGORY_MAP: Record<string, number> = {
'电缆': 28, '电力电缆': 28, '配电箱': 29, '开关柜': 29,
'变压器': 27, '电线': 28, '母线槽': 30, '桥架': 31,
}
const fallbackCategoryId = 1
const mappedItems = products.map(p => {
const catId = CATEGORY_MAP[p.category] || fallbackCategoryId
const item: any = {
name: p.name, category_id: catId,
price: p.price > 0 ? p.price : 0.01,
brand: p.brand || '',
unit: p.unit || '',
sku: p.spec || p.name || '',
}
if (p.imageUrl) item.images = [p.imageUrl]
if (p.supplier) item.remark = `供应商:${p.supplier}`
return item
})
this.logger.log(`batchPublish: 发送 ${mappedItems.length} 个商品到 MCP import_products`)
const result = await this.integrationService.executeTool<any>(
b2bIntegration.id, 'import_products',
{ products: mappedItems },
{ retries: 2, retryDelayMs: 1000 },
)
this.logger.log(`batchPublish: MCP 返回 success=${result.success} data=${result.data ? JSON.stringify(result.data).slice(0, 300) : '(empty)'}`)
if (!result.success) {
throw new ServiceUnavailableException(`B2B 同步失败: ${result.error || '未知错误'}`)
}
// MCP import_products 返回格式: { code:0, msg:"成功", data: [{ status:"success"|"failed", data:{id:"xxx"}, sku, name, error? }] }
const items: Array<{ status: string; data?: any; sku?: string; name?: string; error?: string }> =
(result.data as any)?.data || []
const imported = items.filter(i => i.status === 'success').length
const b2bIds: string[] = items
.filter(i => i.status === 'success' && i.data?.id)
.map(i => String(i.data.id))
const errors: Array<{ sku: string; name: string; error: string }> = items
.filter(i => i.status === 'failed')
.map(i => ({ sku: i.sku || '', name: i.name || '', error: i.error || '未知错误' }))
if (errors.length > 0) {
this.logger.warn(`B2B import_products 导入失败详情: ${errors.map(e => `[${e.sku}] ${e.name}: ${e.error}`).join(' | ')}`)
}
this.logger.log(`B2B import_products 完成: 导入 ${imported} 条, 失败 ${errors.length} 条, B2B IDs: ${b2bIds.join(',')}`)
if (b2bIds.length > 0) {
this.logger.log(`batchPublish: 上架 ${b2bIds.length} 个商品到 B2B publish_products`)
const publishResult = await this.integrationService.executeTool<any>(
b2bIntegration.id, 'publish_products',
{ product_ids: b2bIds },
{ retries: 1, retryDelayMs: 1000 },
)
this.logger.log(`batchPublish: publish_products 返回 success=${publishResult.success}`)
if (!publishResult.success) {
throw new ServiceUnavailableException(`B2B 上架失败: ${publishResult.error || 'publish_products 返回失败'}`)
}
this.logger.log(`B2B 上架成功: ${b2bIds.length} 个商品`)
}
await this.prisma.product.updateMany({
where: { id: { in: ids } }, where: { id: { in: ids } },
data: { status: 'published' }, data: { status: 'published' },
}) })
return { imported, errors, ids: b2bIds }
} }
async remove(id: string) { async remove(id: string) {

View File

@ -6,24 +6,3 @@ body {
background: #f7f9fb; background: #f7f9fb;
color: #191c1e; 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; }
}

View File

@ -2,8 +2,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useAuth } from '@/components/AuthProvider' import { useAuth } from '@/components/AuthProvider'
import { ToastProvider } from '@/components/ui/Toast'
import ErrorBoundary from '@/components/ErrorBoundary'
import Sidebar from '@/components/Sidebar' import Sidebar from '@/components/Sidebar'
import LoginPage from '@/components/LoginPage' import LoginPage from '@/components/LoginPage'
import OverviewPage from '@/components/OverviewPage' import OverviewPage from '@/components/OverviewPage'
@ -16,7 +14,6 @@ import ReportCenterPage from '@/components/ReportCenterPage'
import RuleCenterPage from '@/components/RuleCenterPage' import RuleCenterPage from '@/components/RuleCenterPage'
import SystemMonitorPage from '@/components/SystemMonitorPage' import SystemMonitorPage from '@/components/SystemMonitorPage'
import AdminPage from '@/components/AdminPage' import AdminPage from '@/components/AdminPage'
import IcpFooter from '@/components/IcpFooter'
type Page = 'overview' | 'inquiry' | 'image' | 'product' | 'comparison' | 'lead-detail' | 'report-center' | 'rule-center' | 'system-monitor' | 'admin-tenants' | 'admin-users' | 'admin-stats' | 'admin-configs' | 'admin-plans' type Page = 'overview' | 'inquiry' | 'image' | 'product' | 'comparison' | 'lead-detail' | 'report-center' | 'rule-center' | 'system-monitor' | 'admin-tenants' | 'admin-users' | 'admin-stats' | 'admin-configs' | 'admin-plans'
@ -41,11 +38,9 @@ export default function Home() {
if (!user) return <LoginPage /> if (!user) return <LoginPage />
return ( return (
<ToastProvider>
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<Sidebar current={page} onNavigate={setPage} user={user} onLogout={logout} /> <Sidebar current={page} onNavigate={setPage} user={user} onLogout={logout} />
<main className="flex-1 ml-64 p-8 flex flex-col"> <main className="flex-1 ml-64 p-8">
<ErrorBoundary>
{page === 'overview' && <OverviewPage onNavigate={(p) => setPage(p as Page)} />} {page === 'overview' && <OverviewPage onNavigate={(p) => setPage(p as Page)} />}
{page === 'inquiry' && <InquiryPage onLeadDetail={handleLeadDetail} />} {page === 'inquiry' && <InquiryPage onLeadDetail={handleLeadDetail} />}
{page === 'image' && <ImagePage />} {page === 'image' && <ImagePage />}
@ -56,11 +51,7 @@ export default function Home() {
{page === 'rule-center' && <RuleCenterPage />} {page === 'rule-center' && <RuleCenterPage />}
{page === 'system-monitor' && <SystemMonitorPage />} {page === 'system-monitor' && <SystemMonitorPage />}
{page.startsWith('admin-') && <AdminPage section={page.replace('admin-', '') as any} />} {page.startsWith('admin-') && <AdminPage section={page.replace('admin-', '') as any} />}
</ErrorBoundary>
<div className="flex-1" />
<IcpFooter />
</main> </main>
</div> </div>
</ToastProvider>
) )
} }

View File

@ -2,7 +2,6 @@
import React, { useEffect, useState, useCallback } from 'react' import React, { useEffect, useState, useCallback } from 'react'
import { API_BASE } from '@/lib/apiBase' import { API_BASE } from '@/lib/apiBase'
import { useToast } from '@/components/ui/Toast'
// ---- Types ---- // ---- Types ----
@ -57,15 +56,18 @@ const authHeaders = (): Record<string, string> => {
} }
function toastManager() { function toastManager() {
// Legacy wrapper - now delegates to useToast via context const [toast, setToast] = useState('')
return { toast: '', show: (_msg: string) => {} } const show = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
return { toast, show }
} }
export default function AdminPage({ section }: { section: Tab }) { export default function AdminPage({ section }: { section: Tab }) {
const { showToast } = useToast() const { toast, show: showToast } = toastManager()
return ( return (
<div> <div>
{toast && <div className="fixed top-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg text-sm z-50 shadow-lg">{toast}</div>}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-bold">运营后台</h1> <h1 className="text-2xl font-bold">运营后台</h1>
@ -1000,16 +1002,6 @@ function ConfigTab({ showToast }: { showToast: (m: string) => void }) {
const [testingId, setTestingId] = useState<string | null>(null) const [testingId, setTestingId] = useState<string | null>(null)
const [connectingId, setConnectingId] = useState<string | null>(null) const [connectingId, setConnectingId] = useState<string | null>(null)
// ESC 关闭对接弹窗(填表期间误点遮罩不再触发关闭,避免丢已填内容)
useEffect(() => {
if (!showIntegrationForm) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { setShowIntegrationForm(false); setEditingIntegration(null) }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [showIntegrationForm])
const DEVBOX_ADMIN_APPLY_URL = 'http://100.111.84.16:3000/openapi/app' const DEVBOX_ADMIN_APPLY_URL = 'http://100.111.84.16:3000/openapi/app'
const loadConfigs = useCallback(async () => { const loadConfigs = useCallback(async () => {
@ -1336,19 +1328,10 @@ function ConfigTab({ showToast }: { showToast: (m: string) => void }) {
)} )}
{showIntegrationForm && ( {showIntegrationForm && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => { setShowIntegrationForm(false); setEditingIntegration(null) }}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4"> <div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle"> <div className="p-6 border-b border-border-subtle">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold">{editingIntegration ? '编辑对接' : '新增对接'}</h2> <h2 className="text-lg font-bold">{editingIntegration ? '编辑对接' : '新增对接'}</h2>
<button
onClick={() => { setShowIntegrationForm(false); setEditingIntegration(null) }}
className="text-on-surface-variant hover:text-gray-900 text-2xl leading-none ml-4"
aria-label="关闭"
>
×
</button>
</div>
<p className="text-xs text-on-surface-variant mt-1"> <p className="text-xs text-on-surface-variant mt-1">
APIKey 需先在 APIKey 需先在
<a href={DEVBOX_ADMIN_APPLY_URL} target="_blank" rel="noreferrer" className="text-secondary hover:underline mx-1">devbox B2B 运营后台 → 集成管理 → 应用列表</a> <a href={DEVBOX_ADMIN_APPLY_URL} target="_blank" rel="noreferrer" className="text-secondary hover:underline mx-1">devbox B2B 运营后台 → 集成管理 → 应用列表</a>
@ -1367,9 +1350,6 @@ function ConfigTab({ showToast }: { showToast: (m: string) => void }) {
<input type="text" value={integrationForm.endpoint} onChange={(e) => setIntegrationForm({ ...integrationForm, endpoint: e.target.value })} <input type="text" value={integrationForm.endpoint} onChange={(e) => setIntegrationForm({ ...integrationForm, endpoint: e.target.value })}
placeholder="http://100.111.84.16:3002/mcp" 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" /> className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
<p className="text-xs text-on-surface-variant mt-1">
必须是 Streamable HTTP 协议的 MCP 端点 (POST 方法)。例: <code className="bg-gray-50 px-1 rounded">http://host:port/mcp</code>
</p>
</div> </div>
<div> <div>
<label className="block text-sm font-semibold mb-1">appId *</label> <label className="block text-sm font-semibold mb-1">appId *</label>

View File

@ -2,8 +2,6 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { API_BASE } from '@/lib/apiBase' import { API_BASE } from '@/lib/apiBase'
import { useToast } from '@/components/ui/Toast'
import { SkeletonTable } from '@/components/ui/Skeleton'
interface SupplierInfo { interface SupplierInfo {
id: string id: string
@ -47,7 +45,7 @@ export default function ComparisonPage() {
const [report, setReport] = useState<any>(null) const [report, setReport] = useState<any>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [suppliers, setSuppliers] = useState<SupplierUpload[]>([]) const [suppliers, setSuppliers] = useState<SupplierUpload[]>([])
const { showToast } = useToast() const [toast, setToast] = useState('')
const [sortBy, setSortBy] = useState<'price' | 'name' | 'savings'>('price') const [sortBy, setSortBy] = useState<'price' | 'name' | 'savings'>('price')
const [splitSupplierCount, setSplitSupplierCount] = useState<number | null>(null) const [splitSupplierCount, setSplitSupplierCount] = useState<number | null>(null)
@ -57,6 +55,11 @@ export default function ComparisonPage() {
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 2500)
}
const loadData = async () => { const loadData = async () => {
try { try {
const [compRes, suggRes] = await Promise.all([ const [compRes, suggRes] = await Promise.all([
@ -131,7 +134,7 @@ export default function ComparisonPage() {
const allSupplierNames = [...new Set(data.flatMap(i => i.suppliers.map(s => s.name)))].filter(Boolean) const allSupplierNames = [...new Set(data.flatMap(i => i.suppliers.map(s => s.name)))].filter(Boolean)
if (loading) return <SkeletonTable rows={5} cols={6} /> if (loading) return <div className="text-center py-20 text-on-surface-variant">加载中...</div>
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -626,6 +629,12 @@ export default function ComparisonPage() {
</div> </div>
)} )}
{/* Toast */}
{toast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-6 py-3 rounded-lg text-xs shadow-lg z-50">
{toast}
</div>
)}
</div> </div>
) )
} }

View File

@ -1,79 +0,0 @@
'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<Props, State> {
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 (
<div className="min-h-[400px] flex items-center justify-center">
<div className="text-center max-w-md space-y-4">
<div className="w-16 h-16 bg-rose-50 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-rose-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-on-surface mb-1">页面出错了</h2>
<p className="text-sm text-on-surface-variant">
渲染过程中发生了未知错误,请尝试刷新页面或点击下方重试按钮。
</p>
</div>
{this.state.error && (
<pre className="text-xs text-left bg-slate-50 border border-border-subtle rounded-lg p-3 overflow-x-auto text-rose-600">
{this.state.error.message}
</pre>
)}
<div className="flex gap-3 justify-center">
<button
onClick={this.handleRetry}
className="px-5 py-2 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity"
>
重试
</button>
<button
onClick={() => window.location.reload()}
className="px-5 py-2 bg-white border border-border-subtle rounded-lg text-sm font-semibold hover:bg-slate-50 transition-colors"
>
刷新页面
</button>
</div>
</div>
</div>
)
}
return this.props.children
}
}

View File

@ -1,14 +0,0 @@
export default function IcpFooter() {
return (
<div className="py-4 text-center">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noreferrer noopener"
className="text-xs text-on-surface-variant hover:text-secondary transition-colors"
>
豫ICP备2026009947号-2
</a>
</div>
)
}

View File

@ -4,9 +4,6 @@ import { useEffect, useState, useRef, useCallback } from 'react'
import JSZip from 'jszip' import JSZip from 'jszip'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { API_BASE } from '@/lib/apiBase' 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 { interface ImageRecord {
id: string id: string
@ -48,8 +45,8 @@ export default function ImagePage() {
const [selectedStyle, setSelectedStyle] = useState('white') const [selectedStyle, setSelectedStyle] = useState('white')
const [selectedSize, setSelectedSize] = useState('1:1') const [selectedSize, setSelectedSize] = useState('1:1')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [toast, setToast] = useState('')
const [previewUrl, setPreviewUrl] = useState<string | null>(null) const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const { showToast } = useToast()
// Upload state // Upload state
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
@ -62,13 +59,15 @@ export default function ImagePage() {
const doneImages = images.filter(i => i.status === 'done') const doneImages = images.filter(i => i.status === 'done')
const matchedImages = images.filter(i => i.status === 'done' && Object.values(matchMap).includes(i.id)) const matchedImages = images.filter(i => i.status === 'done' && Object.values(matchMap).includes(i.id))
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 2500)
}
useEffect(() => { useEffect(() => {
fetch(API) fetch(API)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(setImages)
setImages(Array.isArray(data) ? data : [])
})
.catch(() => setImages([]))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
@ -290,19 +289,7 @@ export default function ImagePage() {
} }
}, [matchMap, productList, images]) }, [matchMap, productList, images])
if (loading) return ( if (loading) return <div className="text-center py-20 text-on-surface-variant">加载中...</div>
<div className="space-y-4">
<div className="flex justify-between items-end">
<div>
<div className="h-7 w-40 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-64 bg-slate-100 rounded animate-pulse mt-2" />
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{Array.from({ length: 12 }).map((_, i) => <SkeletonCard key={i} />)}
</div>
</div>
)
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -659,7 +646,23 @@ export default function ImagePage() {
</div> </div>
{/* Preview Lightbox */} {/* Preview Lightbox */}
<Lightbox src={previewUrl} onClose={() => setPreviewUrl(null)} /> {previewUrl && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-8" onClick={() => setPreviewUrl(null)}>
<div className="relative max-w-[90vw] max-h-[90vh]" onClick={e => e.stopPropagation()}>
<img src={previewUrl} alt="预览" className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl" />
<button onClick={() => setPreviewUrl(null)} className="absolute -top-3 -right-3 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-slate-100">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
)}
{/* Toast */}
{toast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-6 py-3 rounded-lg text-xs shadow-lg z-50">
{toast}
</div>
)}
</div> </div>
) )
} }

View File

@ -1,10 +1,7 @@
'use client' 'use client'
import { useEffect, useState, useCallback, useRef, useMemo } from 'react' import { useEffect, useState, useCallback } from 'react'
import { API_BASE } from '@/lib/apiBase' 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 { interface Lead {
id: string id: string
@ -38,7 +35,7 @@ function mapLead(raw: any): Lead {
id: raw.id, id: raw.id,
title: raw.title, title: raw.title,
star: raw.star || 0, star: raw.star || 0,
level: raw.level ? raw.level + '级' : 'C级', level: raw.level || 'C',
amount: raw.amount || '待确认', amount: raw.amount || '待确认',
status: raw.status || '待复核', status: raw.status || '待复核',
deadline: raw.deadline || '', deadline: raw.deadline || '',
@ -61,8 +58,9 @@ function mapLead(raw: any): Lead {
} }
const STATUS_OPTIONS = ['全部状态', '待复核', '待跟进', '跟进中', '已转询价', '已转协同', '已成交', '无效线索'] const STATUS_OPTIONS = ['全部状态', '待复核', '待跟进', '跟进中', '已转询价', '已转协同', '已成交', '无效线索']
const LEVEL_OPTIONS = ['全部级别', 'S级', 'A级', 'B级', 'C级', 'D级'] const LEVEL_OPTIONS = ['全部级别', 'S级', 'A级', 'B级']
const SOURCE_OPTIONS = ['全部来源', '中电建', '新矿集团', '中核集团'] const CAT_OPTIONS = ['全部品类', '防火门', '配电箱', '电线电缆', '阀门', '钢材', '涂料', '混凝土']
const SOURCE_OPTIONS = ['全部来源', 'PowerChina 招采', '新矿集团', '发改委审批', '行业资讯']
const PERSON_OPTIONS = ['全部人员', '郑钊', '赵主任', '运营管理员', '未分配'] const PERSON_OPTIONS = ['全部人员', '郑钊', '赵主任', '运营管理员', '未分配']
function getStatusClass(status: string) { function getStatusClass(status: string) {
@ -98,7 +96,6 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
const [showSModal, setShowSModal] = useState(false) const [showSModal, setShowSModal] = useState(false)
const [showPubModal, setShowPubModal] = useState(false) const [showPubModal, setShowPubModal] = useState(false)
const [pubLoading, setPubLoading] = useState(false) const [pubLoading, setPubLoading] = useState(false)
const [pubForm, setPubForm] = useState({ contactName: '', contactPhone: '', deliveryAddress: '' })
const [publishFailModal, setPublishFailModal] = useState<{ const [publishFailModal, setPublishFailModal] = useState<{
failed: Array<{ title: string; error: string }> failed: Array<{ title: string; error: string }>
success: number success: number
@ -108,47 +105,22 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
const [batchStatus, setBatchStatus] = useState('跟进中') const [batchStatus, setBatchStatus] = useState('跟进中')
const [batchLoading, setBatchLoading] = useState(false) const [batchLoading, setBatchLoading] = useState(false)
const [batchNote, setBatchNote] = useState('') const [batchNote, setBatchNote] = useState('')
const { showToast } = useToast() const [toast, setToast] = useState('')
const pageSize = 10 const pageSize = 10
const [fSearch, setFSearch] = useState('') const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
const [fTime, setFTime] = useState('全部日期')
const [fTime, setFTime] = useState('全部时间')
const [fStar, setFStar] = useState('全部星级') const [fStar, setFStar] = useState('全部星级')
const [fStatus, setFStatus] = useState('全部状态') const [fStatus, setFStatus] = useState('全部状态')
const [fLevel, setFLevel] = useState('全部级别') const [fLevel, setFLevel] = useState('全部级别')
const [fCat, setFCat] = useState('全部品类') const [fCat, setFCat] = useState('全部品类')
const [catOptions, setCatOptions] = useState<string[]>(['全部品类'])
const [catInput, setCatInput] = useState('')
const [catOpen, setCatOpen] = useState(false)
const [catHighlightIdx, setCatHighlightIdx] = useState(0)
const catRef = useRef<HTMLDivElement>(null)
const filteredCatOptions = useMemo(() => {
const q = catInput.trim().toLowerCase()
if (!q) return catOptions
return catOptions.filter((c) => c.toLowerCase().includes(q))
}, [catInput, catOptions])
useEffect(() => {
if (!catOpen) return
const handler = (e: MouseEvent) => {
if (catRef.current && !catRef.current.contains(e.target as Node)) setCatOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [catOpen])
useEffect(() => { setCatHighlightIdx(0) }, [catInput, catOpen])
const selectCat = (cat: string) => {
setFCat(cat)
setCatInput('')
setCatOpen(false)
setCatHighlightIdx(0)
}
const [fSource, setFSource] = useState('全部来源') const [fSource, setFSource] = useState('全部来源')
const [fPerson, setFPerson] = useState('全部人员') const [fPerson, setFPerson] = useState('全部人员')
const loadLeads = useCallback(async (search?: string, star?: string, status?: string, level?: string, cat?: string, source?: string, person?: string, timeRange?: string) => { const loadLeads = useCallback(async (star?: string, status?: string, level?: string, cat?: string, source?: string, person?: string) => {
setLoading(true) setLoading(true)
const params = new URLSearchParams() const params = new URLSearchParams()
if (search && search.trim()) params.set('search', search.trim())
if (star && star !== '全部星级') { if (star && star !== '全部星级') {
if (star === '5星') params.set('star', '5') if (star === '5星') params.set('star', '5')
else if (star === '4星') params.set('star', '4') else if (star === '4星') params.set('star', '4')
@ -156,12 +128,11 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
else if (star === '2星及以下') params.set('star', '2星及以下') else if (star === '2星及以下') params.set('star', '2星及以下')
} }
if (status && status !== '全部状态') params.set('status', status) if (status && status !== '全部状态') params.set('status', status)
if (level && level !== '全部级别') params.set('level', level.replace('级', '')) if (level && level !== '全部级别') params.set('level', level)
if (cat && cat !== '全部品类') params.set('category', cat) if (cat && cat !== '全部品类') params.set('category', cat)
if (source && source !== '全部来源') params.set('source', source) if (source && source !== '全部来源') params.set('source', source)
if (person && person !== '全部人员' && person !== '未分配') params.set('assignedTo', person) if (person && person !== '全部人员' && person !== '未分配') params.set('assignedTo', person)
if (person === '未分配') params.set('assignedTo', '未分配') if (person === '未分配') params.set('assignedTo', '未分配')
if (timeRange && timeRange !== '全部日期') params.set('timeRange', timeRange)
try { try {
const qs = params.toString() const qs = params.toString()
const data: any[] = await fetch(`${API}${qs ? '?' + qs : ''}`).then(r => r.json()) const data: any[] = await fetch(`${API}${qs ? '?' + qs : ''}`).then(r => r.json())
@ -173,16 +144,10 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
setLoading(false) setLoading(false)
}, []) }, [])
useEffect(() => { useEffect(() => { loadLeads(fStar, fStatus, fLevel, fCat, fSource, fPerson) }, [loadLeads, fStar, fStatus, fLevel, fCat, fSource, fPerson])
fetch(`${API_BASE}/api/categories`).then(r => r.json()).then((data: any[]) => {
setCatOptions(['全部品类', ...data.map((c: any) => c.name)])
}).catch(() => {})
}, [])
useEffect(() => { loadLeads(fSearch, fStar, fStatus, fLevel, fCat, fSource, fPerson, fTime) }, [loadLeads, fSearch, fStar, fStatus, fLevel, fCat, fSource, fPerson, fTime])
const refreshLeads = async () => { const refreshLeads = async () => {
await loadLeads(fSearch, fStar, fStatus, fLevel, fCat, fSource, fPerson, fTime) await loadLeads(fStar, fStatus, fLevel, fCat, fSource, fPerson)
} }
const totalPages = Math.ceil(filtered.length / pageSize) const totalPages = Math.ceil(filtered.length / pageSize)
@ -228,13 +193,6 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
const oneClickPublish = () => { const oneClickPublish = () => {
const selected = filtered.filter(l => selectedIds.has(l.id)) const selected = filtered.filter(l => selectedIds.has(l.id))
if (selected.length === 0) return if (selected.length === 0) return
// 从第一个选中线索预填联系人/地址
const first = selected[0]
setPubForm({
contactName: first.contact || '',
contactPhone: first.phone || '',
deliveryAddress: first.regionDetail || first.region || '',
})
setShowPubModal(true) setShowPubModal(true)
} }
@ -254,15 +212,7 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
const failed: Array<{ title: string; error: string }> = [] const failed: Array<{ title: string; error: string }> = []
for (const lead of selected) { for (const lead of selected) {
try { try {
const res = await fetch(`${API}/${lead.id}/publish`, { const res = await fetch(`${API}/${lead.id}/publish`, { method: 'POST' })
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) { if (res.ok) {
lead.publish = '已发布' lead.publish = '已发布'
success++ success++
@ -442,9 +392,11 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
<div className="bg-white p-4 border border-border-subtle rounded-lg shadow-sm flex flex-wrap items-center gap-4"> <div className="bg-white p-4 border border-border-subtle rounded-lg shadow-sm flex flex-wrap items-center gap-4">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
{[ {[
{ label: '公告日期', value: fTime, set: setFTime, opts: ['全部日期', '今天', '昨天', '本周', '本月'] }, { label: '时间范围', value: fTime, set: setFTime, opts: ['全部时间', '今天', '昨天', '本周', '本月'] },
{ label: '星级', value: fStar, set: setFStar, opts: ['全部星级', '5星', '4星', '3星', '2星及以下'] }, { label: '星级', value: fStar, set: setFStar, opts: ['全部星级', '5星', '4星', '3星', '2星及以下'] },
{ label: '状态', value: fStatus, set: setFStatus, opts: STATUS_OPTIONS },
{ label: '甲方级别', value: fLevel, set: setFLevel, opts: LEVEL_OPTIONS }, { label: '甲方级别', value: fLevel, set: setFLevel, opts: LEVEL_OPTIONS },
{ label: '品类', value: fCat, set: setFCat, opts: CAT_OPTIONS },
{ label: '来源平台', value: fSource, set: setFSource, opts: SOURCE_OPTIONS }, { label: '来源平台', value: fSource, set: setFSource, opts: SOURCE_OPTIONS },
{ label: '负责人', value: fPerson, set: setFPerson, opts: PERSON_OPTIONS }, { label: '负责人', value: fPerson, set: setFPerson, opts: PERSON_OPTIONS },
].map(f => ( ].map(f => (
@ -455,74 +407,6 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
</select> </select>
</div> </div>
))} ))}
<div ref={catRef} className="flex flex-col gap-1 relative">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">品类</span>
<div className="relative">
<input
type="text"
value={catInput || (fCat === '全部品类' ? '' : fCat)}
placeholder={fCat === '全部品类' ? '输入关键词搜索品类' : fCat}
onFocus={() => setCatOpen(true)}
onChange={e => { setCatInput(e.target.value); setCatOpen(true) }}
onKeyDown={e => {
if (e.key === 'ArrowDown') { e.preventDefault(); setCatHighlightIdx(i => Math.min(filteredCatOptions.length - 1, i + 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setCatHighlightIdx(i => Math.max(0, i - 1)) }
else if (e.key === 'Enter') { e.preventDefault(); if (filteredCatOptions[catHighlightIdx]) selectCat(filteredCatOptions[catHighlightIdx]) }
else if (e.key === 'Escape') { setCatOpen(false); setCatInput('') }
else if (e.key === 'Backspace' && !catInput && fCat !== '全部品类') { e.preventDefault(); selectCat('全部品类') }
}}
className="bg-surface-container-low border border-border-subtle rounded pl-2 pr-6 py-1 text-xs w-32 focus:ring-secondary outline-none placeholder:text-slate-400"
/>
{fCat !== '全部品类' && (
<button
type="button"
onClick={() => selectCat('全部品类')}
className="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full text-[10px]"
title="清空筛选"
>×</button>
)}
</div>
{catOpen && (
<div className="absolute top-full left-0 mt-1 w-48 max-h-64 overflow-y-auto bg-white border border-border-subtle rounded-md shadow-lg z-30">
{filteredCatOptions.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-slate-400">无匹配品类</div>
) : (
filteredCatOptions.map((c, idx) => (
<button
key={c}
type="button"
onMouseEnter={() => setCatHighlightIdx(idx)}
onClick={() => selectCat(c)}
className={`w-full text-left px-3 py-1.5 text-xs flex items-center justify-between ${idx === catHighlightIdx ? 'bg-secondary text-white' : 'hover:bg-slate-50 text-slate-700'}`}
>
<span>{c}</span>
{fCat === c && <span className={idx === catHighlightIdx ? 'text-white' : 'text-secondary'}>✓</span>}
</button>
))
)}
</div>
)}
</div>
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">搜索</span>
<div className="relative">
<input
type="text"
value={fSearch}
onChange={e => setFSearch(e.target.value)}
placeholder="输入关键词搜索"
className="bg-surface-container-low border border-border-subtle rounded pl-2 pr-6 py-1 text-xs w-40 focus:ring-secondary outline-none placeholder:text-slate-400"
/>
{fSearch && (
<button
type="button"
onClick={() => setFSearch('')}
className="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full text-[10px]"
title="清空搜索"
>×</button>
)}
</div>
</div>
</div> </div>
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
<span className="text-xs text-on-surface-variant">批量操作:</span> <span className="text-xs text-on-surface-variant">批量操作:</span>
@ -561,7 +445,8 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
<th className="p-4 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">星级</th> <th className="p-4 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">星级</th>
<th className="p-4 w-16 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">甲方级别</th> <th className="p-4 w-16 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">甲方级别</th>
<th className="p-4 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right whitespace-nowrap">金额(万)</th> <th className="p-4 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right whitespace-nowrap">金额(万)</th>
<th className="p-4 w-16 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">品类</th> <th className="p-4 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">数量</th>
<th className="p-4 w-16 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">状态</th>
<th className="p-4 w-24 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">投标截止</th> <th className="p-4 w-24 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">投标截止</th>
<th className="p-4 w-24 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">公告日期</th> <th className="p-4 w-24 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">公告日期</th>
<th className="p-4 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">发布状态</th> <th className="p-4 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase whitespace-nowrap">发布状态</th>
@ -569,9 +454,9 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
</thead> </thead>
<tbody className="divide-y divide-border-subtle"> <tbody className="divide-y divide-border-subtle">
{loading ? ( {loading ? (
<tr><td colSpan={10} className="p-8"><SkeletonTable rows={5} cols={10} /></td></tr> <tr><td colSpan={11} className="p-8 text-center text-sm text-on-surface-variant">加载中...</td></tr>
) : pageLeads.length === 0 ? ( ) : pageLeads.length === 0 ? (
<tr><td colSpan={10} className="p-8 text-center text-sm text-on-surface-variant">暂无数据</td></tr> <tr><td colSpan={11} className="p-8 text-center text-sm text-on-surface-variant">暂无数据</td></tr>
) : pageLeads.map(lead => { ) : pageLeads.map(lead => {
const stars = '★'.repeat(lead.star) + '☆'.repeat(5 - lead.star) const stars = '★'.repeat(lead.star) + '☆'.repeat(5 - lead.star)
return ( return (
@ -591,7 +476,8 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
<td className="p-4 text-right whitespace-nowrap"> <td className="p-4 text-right whitespace-nowrap">
<span className={lead.amount === '待确认' ? 'text-[10px] text-slate-400' : 'text-[11px] font-medium'}>{lead.amount}</span> <span className={lead.amount === '待确认' ? 'text-[10px] text-slate-400' : 'text-[11px] font-medium'}>{lead.amount}</span>
</td> </td>
<td className="p-4 whitespace-nowrap"><span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${lead.cat ? 'bg-purple-50 text-purple-700' : 'bg-slate-100 text-slate-400'}`}>{lead.cat || '-'}</span></td> <td className="p-4 text-[10px] text-slate-500 whitespace-nowrap">{lead.quantity ? `${lead.quantity}${lead.unit || ''}` : '-'}</td>
<td className="p-4 whitespace-nowrap"><span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${getStatusClass(lead.status)}`}>{lead.status}</span></td>
<td className="p-4 text-[10px] text-slate-500 whitespace-nowrap">{lead.deadline ? lead.deadline.slice(5, 10) : '-'}</td> <td className="p-4 text-[10px] text-slate-500 whitespace-nowrap">{lead.deadline ? lead.deadline.slice(5, 10) : '-'}</td>
<td className="p-4 text-[10px] text-slate-500 whitespace-nowrap">{lead.publishTime ? lead.publishTime.slice(0, 10) : '-'}</td> <td className="p-4 text-[10px] text-slate-500 whitespace-nowrap">{lead.publishTime ? lead.publishTime.slice(0, 10) : '-'}</td>
<td className="p-4 whitespace-nowrap"> <td className="p-4 whitespace-nowrap">
@ -740,33 +626,6 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
<p className="text-xs text-slate-600">{drawerLead.publishTime || '-'}</p> <p className="text-xs text-slate-600">{drawerLead.publishTime || '-'}</p>
</div> </div>
<div className="flex gap-2 pt-2 border-t border-border-subtle"> <div className="flex gap-2 pt-2 border-t border-border-subtle">
{drawerLead.publish !== '已发布' && (
<button
className="flex-1 py-2 bg-emerald-600 text-white text-center rounded-lg text-xs font-semibold hover:bg-emerald-700 disabled:opacity-50"
disabled={pubLoading}
onClick={async () => {
setPubLoading(true)
try {
const res = await fetch(`${API}/${drawerLead.id}/publish`, { method: 'POST' })
if (res.ok) {
drawerLead.publish = '已发布'
setFiltered([...filtered])
showToast('发布成功')
setDrawerLead({ ...drawerLead })
} else {
const errBody = await res.json().catch(() => ({}))
showToast(errBody.message || `发布失败 (${res.status})`)
}
} catch (e) {
showToast(e instanceof Error ? e.message : '发布失败')
} finally {
setPubLoading(false)
}
}}
>
{pubLoading ? '发布中...' : '发布'}
</button>
)}
<button className="flex-1 py-2 bg-secondary text-white text-center rounded-lg text-xs font-semibold hover:opacity-90" onClick={() => { onLeadDetail?.(drawerLead.id); setDrawerLead(null) }}>查看完整详情</button> <button className="flex-1 py-2 bg-secondary text-white text-center rounded-lg text-xs font-semibold hover:opacity-90" onClick={() => { onLeadDetail?.(drawerLead.id); setDrawerLead(null) }}>查看完整详情</button>
<button className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50" onClick={() => setDrawerLead(null)}>关闭</button> <button className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50" onClick={() => setDrawerLead(null)}>关闭</button>
</div> </div>
@ -776,13 +635,23 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
)} )}
{/* S级甲方动态 Modal */} {/* S级甲方动态 Modal */}
<Modal {showSModal && (
open={showSModal} <div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowSModal(false)}>
onClose={() => setShowSModal(false)} <div className="bg-white rounded-xl shadow-2xl w-[520px] max-h-[calc(100vh-120px)] overflow-y-auto transform" onClick={e => e.stopPropagation()}>
title="S级甲方动态" <div className="p-5 border-b border-border-subtle flex justify-between items-center">
subtitle="近7日动态 · 按时间倒序排列" <h3 className="text-base font-semibold flex items-center gap-2">
width="w-[520px]" <svg className="w-5 h-5 text-secondary" fill="currentColor" viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" /></svg>
> S级甲方动态
</h3>
<button onClick={() => setShowSModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5">
<div className="text-xs text-slate-400 mb-4 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
近7日动态 · 按时间倒序排列
</div>
<div className="space-y-3"> <div className="space-y-3">
{sSorted.map(l => ( {sSorted.map(l => (
<div key={l.id} className="p-3 rounded-lg bg-slate-50 hover:bg-blue-50 transition-colors cursor-pointer border border-slate-100"> <div key={l.id} className="p-3 rounded-lg bg-slate-50 hover:bg-blue-50 transition-colors cursor-pointer border border-slate-100">
@ -806,114 +675,77 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
</div> </div>
))} ))}
</div> </div>
</Modal> </div>
</div>
</div>
)}
{/* 一键发布 Modal */} {/* 一键发布 Modal */}
{showPubModal && (() => { {showPubModal && (() => {
const lead = filtered.find(l => selectedIds.has(l.id)) const lead = filtered.find(l => selectedIds.has(l.id))
if (!lead) return null if (!lead) return null
const selectedCount = filtered.filter(l => selectedIds.has(l.id)).length
return ( return (
<Modal <div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!pubLoading) setShowPubModal(false) }}>
open={showPubModal} <div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
onClose={() => setShowPubModal(false)} <div className="p-5 border-b border-border-subtle flex justify-between items-center">
title="确认发布询价单" <h3 className="text-base font-semibold">一键发布 direv</h3>
subtitle={`即将发布至 www.direv.com.cn(共 ${selectedCount} 条线索)`} <button disabled={pubLoading} onClick={() => setShowPubModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
width="w-[560px]" <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
loading={pubLoading} </button>
footer={ </div>
<> <div className="p-5 space-y-4">
<div className="text-xs">即将发布以下线索至 <b>direv.com.cn</b>:</div>
<div className="p-3 bg-slate-50 rounded text-xs space-y-1">
<b>{lead.title}</b><br />
品类:{lead.cat} | 数量:待确认 | 截止:{lead.deadline}<br />
联系:郑钊 15532027557
</div>
<div className="text-[10px] text-slate-400">将自动匹配联系人、收货地址等信息后发布。</div>
<div className="flex gap-3 pt-2">
<button disabled={pubLoading} onClick={doPublish} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50"> <button disabled={pubLoading} onClick={doPublish} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
{pubLoading ? '发布中...' : '确认发布'} {pubLoading ? '发布中...' : '确认发布'}
</button> </button>
<button disabled={pubLoading} onClick={() => setShowPubModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button> <button disabled={pubLoading} onClick={() => setShowPubModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
</>
}
>
<div className="space-y-4 text-sm">
{/* 询价信息 */}
<div>
<h4 className="text-xs font-semibold text-on-surface-variant mb-2">— 询价信息(从线索自动填充)</h4>
<div className="p-3 bg-slate-50 rounded text-xs space-y-1.5">
<div><span className="text-on-surface-variant">询价标题:</span><b>{lead.title}</b></div>
<div><span className="text-on-surface-variant">采购产品:</span>{lead.title.slice(0, 40)}</div>
<div className="flex gap-4">
<span><span className="text-on-surface-variant">数量:</span>{lead.quantity || '待确认'}</span>
<span><span className="text-on-surface-variant">单位:</span>{lead.unit || '批'}</span>
</div>
<div><span className="text-on-surface-variant">品类:</span>{lead.cat || '未分类'} <span className="text-on-surface-variant ml-4">截止:</span>{lead.deadline || '无'}</div>
</div>
</div>
{/* 收货信息(用户可编辑) */}
<div>
<h4 className="text-xs font-semibold text-on-surface-variant mb-2">— 收货信息 <span className="text-error-red">*必填</span></h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium mb-1">联系人</label>
<input type="text" value={pubForm.contactName}
onChange={(e) => 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">联系手机 *</label>
<input type="tel" value={pubForm.contactPhone}
onChange={(e) => 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 && <p className="text-error-red text-[10px] mt-0.5">⚠ 必填</p>}
</div>
<div className="col-span-2">
<label className="block text-xs font-medium mb-1">收货地址 *</label>
<input type="text" value={pubForm.deliveryAddress}
onChange={(e) => 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 && <p className="text-error-red text-[10px] mt-0.5">⚠ 必填</p>}
</div>
<div>
<label className="block text-xs font-medium mb-1">交货日期</label>
<input type="date" value={lead.deadline ? lead.deadline.slice(0, 10) : ''} disabled
className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs bg-gray-50 outline-none text-on-surface-variant" />
</div>
<div>
<label className="block text-xs font-medium mb-1">报价有效期</label>
<input type="text" value="7 天(默认)" disabled
className="w-full px-3 py-1.5 border border-border-subtle rounded text-xs bg-gray-50 outline-none text-on-surface-variant" />
</div> </div>
</div> </div>
</div> </div>
{/* 业务默认值 */}
<div>
<h4 className="text-xs font-semibold text-on-surface-variant mb-2">— 业务默认值(企业配置,不可修改)</h4>
<div className="grid grid-cols-2 gap-2 text-xs p-3 bg-gray-50 rounded">
<div><span className="text-on-surface-variant">询价主体:</span>郑州巨变网络科技有限公司</div>
<div><span className="text-on-surface-variant">采购类型:</span>公开采购 (1)</div>
<div><span className="text-on-surface-variant">开票要求:</span>增值税专票 (2)</div>
<div><span className="text-on-surface-variant">运费承担:</span>含运费 (1)</div>
<div><span className="text-on-surface-variant">报价要求:</span>需要报价 (1)</div>
<div><span className="text-on-surface-variant">付款方式:</span>企业默认(不传)</div>
</div> </div>
</div>
</div>
</Modal>
) )
})()} })()}
{/* 发布失败 Modal */} {/* 发布失败 Modal */}
{publishFailModal && ( {publishFailModal && (
<Modal <div className="fixed inset-0 z-50 bg-black/40 flex items-start justify-center pt-20" onClick={() => setPublishFailModal(null)}>
open={!!publishFailModal} <div className="bg-white rounded-xl shadow-2xl w-[640px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
onClose={() => setPublishFailModal(null)} <div className="p-5 border-b border-border-subtle flex justify-between items-center">
title="发布失败" <div>
subtitle={`以下 ${publishFailModal.failed.length} 条线索未成功发布到 B2B 平台${publishFailModal.success > 0 ? `(成功 ${publishFailModal.success} 条)` : ''},请检查 MCP 对接配置后重试。`} <h3 className="text-base font-semibold flex items-center gap-2">
width="w-[640px]" <svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
footer={ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<> </svg>
发布失败
</h3>
<p className="text-xs text-on-surface-variant mt-1">
以下 {publishFailModal.failed.length} 条线索未成功发布到 B2B 平台
{publishFailModal.success > 0 ? `(成功 ${publishFailModal.success} 条)` : ''}
,请检查 MCP 对接配置后重试。
</p>
</div>
<button onClick={() => setPublishFailModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5 space-y-3 overflow-y-auto flex-1">
{publishFailModal.failed.map((f, i) => (
<div key={i} className="border border-red-200 bg-red-50/50 rounded-lg p-3">
<div className="text-xs font-semibold text-red-900 mb-1 truncate">{f.title}</div>
<div className="text-[11px] text-red-700 leading-relaxed">{f.error}</div>
</div>
))}
</div>
<div className="p-4 border-t border-border-subtle flex gap-2 justify-end">
<a <a
href="#" href="#"
onClick={e => { e.preventDefault(); setPublishFailModal(null); window.open('http://localhost:3001/#config', '_blank') }} onClick={e => { e.preventDefault(); setPublishFailModal(null); window.open('http://localhost:3001/#config', '_blank') }}
@ -924,61 +756,48 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
<button onClick={() => setPublishFailModal(null)} className="px-4 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90"> <button onClick={() => setPublishFailModal(null)} className="px-4 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90">
我知道了 我知道了
</button> </button>
</>
}
>
<div className="space-y-3">
{publishFailModal.failed.map((f, i) => (
<div key={i} className="border border-red-200 bg-red-50/50 rounded-lg p-3">
<div className="text-xs font-semibold text-red-900 mb-1 truncate">{f.title}</div>
<div className="text-[11px] text-red-700 leading-relaxed">{f.error}</div>
</div> </div>
))}
</div> </div>
</Modal> </div>
)} )}
{/* 分配负责人 Modal */} {/* 分配负责人 Modal */}
<Modal {batchModal === 'assign' && (
open={batchModal === 'assign'} <div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
onClose={() => setBatchModal(null)} <div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
title="分配负责人" <div className="p-5 border-b border-border-subtle flex justify-between items-center">
width="w-[480px]" <h3 className="text-base font-semibold">分配负责人</h3>
loading={batchLoading} <button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
footer={ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
<>
<button disabled={batchLoading} onClick={confirmBatchAssign} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
{batchLoading ? '处理中...' : '确认分配'}
</button> </button>
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button> </div>
</> <div className="p-5 space-y-4">
}
>
<div className="space-y-4">
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索分配给:</div> <div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索分配给:</div>
<select value={batchAssignee} onChange={e => setBatchAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20"> <select value={batchAssignee} onChange={e => setBatchAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
{PERSON_OPTIONS.filter(p => p !== '全部人员' && p !== '未分配').map(p => <option key={p}>{p}</option>)} {PERSON_OPTIONS.filter(p => p !== '全部人员' && p !== '未分配').map(p => <option key={p}>{p}</option>)}
</select> </select>
</div> <div className="flex gap-3 pt-2">
</Modal> <button disabled={batchLoading} onClick={confirmBatchAssign} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
{batchLoading ? '处理中...' : '确认分配'}
{/* 批量转询价 Modal */}
<Modal
open={batchModal === 'rfq'}
onClose={() => setBatchModal(null)}
title="批量转询价"
width="w-[520px]"
loading={batchLoading}
footer={
<>
<button disabled={batchLoading} onClick={batchToRFQ} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
{batchLoading ? '处理中...' : '确认转询价'}
</button> </button>
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button> <button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
</> </div>
} </div>
> </div>
<div className="space-y-4"> </div>
)}
{/* 批量转询价 Modal */}
{batchModal === 'rfq' && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
<div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">批量转询价</h3>
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建询价单:</div> <div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建询价单:</div>
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1"> <div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)} {selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
@ -995,26 +814,28 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span> <span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
<textarea value={batchNote} onChange={e => setBatchNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选,预填询价备注" /> <textarea value={batchNote} onChange={e => setBatchNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选,预填询价备注" />
</div> </div>
</div> <div className="flex gap-3 pt-2">
</Modal> <button disabled={batchLoading} onClick={batchToRFQ} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
{batchLoading ? '处理中...' : '确认转询价'}
{/* 批量转协同 Modal */}
<Modal
open={batchModal === 'collab'}
onClose={() => setBatchModal(null)}
title="批量转协同"
width="w-[520px]"
loading={batchLoading}
footer={
<>
<button disabled={batchLoading} onClick={batchToCollab} className="flex-1 py-2 bg-white border border-purple-200 text-purple-700 rounded-lg text-xs font-semibold hover:bg-purple-50 disabled:opacity-50">
{batchLoading ? '处理中...' : '确认转协同'}
</button> </button>
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button> <button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
</> </div>
} </div>
> </div>
<div className="space-y-4"> </div>
)}
{/* 批量转协同 Modal */}
{batchModal === 'collab' && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
<div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">批量转协同</h3>
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建协同任务:</div> <div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建协同任务:</div>
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1"> <div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)} {selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
@ -1031,32 +852,48 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span> <span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
<textarea value={batchNote} onChange={e => setBatchNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" /> <textarea value={batchNote} onChange={e => setBatchNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
</div> </div>
</div> <div className="flex gap-3 pt-2">
</Modal> <button disabled={batchLoading} onClick={batchToCollab} className="flex-1 py-2 bg-white border border-purple-200 text-purple-700 rounded-lg text-xs font-semibold hover:bg-purple-50 disabled:opacity-50">
{batchLoading ? '处理中...' : '确认转协同'}
{/* 变更状态 Modal */}
<Modal
open={batchModal === 'status'}
onClose={() => setBatchModal(null)}
title="变更状态"
width="w-[480px]"
loading={batchLoading}
footer={
<>
<button disabled={batchLoading} onClick={confirmBatchStatus} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
{batchLoading ? '处理中...' : '确认变更'}
</button> </button>
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button> <button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
</> </div>
} </div>
> </div>
<div className="space-y-4"> </div>
)}
{/* 变更状态 Modal */}
{batchModal === 'status' && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">变更状态</h3>
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索状态变更为:</div> <div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索状态变更为:</div>
<select value={batchStatus} onChange={e => setBatchStatus(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20"> <select value={batchStatus} onChange={e => setBatchStatus(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
{STATUS_OPTIONS.filter(s => s !== '全部状态' && s !== '无效线索').map(s => <option key={s}>{s}</option>)} {STATUS_OPTIONS.filter(s => s !== '全部状态' && s !== '无效线索').map(s => <option key={s}>{s}</option>)}
</select> </select>
<div className="flex gap-3 pt-2">
<button disabled={batchLoading} onClick={confirmBatchStatus} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
{batchLoading ? '处理中...' : '确认变更'}
</button>
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
</div> </div>
</Modal> </div>
</div>
</div>
)}
{toast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-5 py-2.5 rounded-lg text-sm shadow-lg z-50">
{toast}
</div>
)}
</div> </div>
) )
} }

View File

@ -2,8 +2,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { API_BASE } from '@/lib/apiBase' import { API_BASE } from '@/lib/apiBase'
import { useToast } from '@/components/ui/Toast'
import Modal from '@/components/ui/Modal'
interface FollowUp { interface FollowUp {
date: string date: string
@ -105,13 +103,14 @@ export default function LeadDetailPage({ leadId, onNavigate }: { leadId: string;
const [newFollowContent, setNewFollowContent] = useState('') const [newFollowContent, setNewFollowContent] = useState('')
const [newFollowType, setNewFollowType] = useState<'电话沟通' | '邮件往来' | '现场拜访' | '线上会议'>('电话沟通') const [newFollowType, setNewFollowType] = useState<'电话沟通' | '邮件往来' | '现场拜访' | '线上会议'>('电话沟通')
const [bidExpanded, setBidExpanded] = useState(false) const [bidExpanded, setBidExpanded] = useState(false)
const { showToast } = useToast() const [toast, setToast] = useState('')
const [actionLoading, setActionLoading] = useState<string | null>(null) const [actionLoading, setActionLoading] = useState<string | null>(null)
const [showRFQModal, setShowRFQModal] = useState(false) const [showRFQModal, setShowRFQModal] = useState(false)
const [showCollabModal, setShowCollabModal] = useState(false) const [showCollabModal, setShowCollabModal] = useState(false)
const [modalAssignee, setModalAssignee] = useState('郑钊') const [modalAssignee, setModalAssignee] = useState('郑钊')
const [modalNote, setModalNote] = useState('') const [modalNote, setModalNote] = useState('')
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
useEffect(() => { useEffect(() => {
if (!leadId) return if (!leadId) return
@ -553,22 +552,50 @@ export default function LeadDetailPage({ leadId, onNavigate }: { leadId: string;
</div> </div>
{/* 转询价 Modal */} {/* 转询价 Modal */}
<Modal {showRFQModal && (
open={showRFQModal} <div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!actionLoading) setShowRFQModal(false) }}>
onClose={() => setShowRFQModal(false)} <div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
title="转为询价" <div className="p-5 border-b border-border-subtle flex justify-between items-center">
width="w-[480px]" <h3 className="text-base font-semibold">转为询价</h3>
loading={!!actionLoading} <button disabled={!!actionLoading} onClick={() => setShowRFQModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
footer={ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
<> </button>
</div>
<div className="p-5 space-y-4">
<div>
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
<select value={modalAssignee} onChange={e => setModalAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
<option value="郑钊">郑钊</option>
<option value="赵主任">赵主任</option>
<option value="运营管理员">运营管理员</option>
</select>
</div>
<div>
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
<textarea value={modalNote} onChange={e => setModalNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
</div>
<div className="flex gap-3 pt-2">
<button disabled={!!actionLoading} onClick={async () => { await doAction('to-rfq', 'POST', { assignee: modalAssignee, note: modalNote }); setShowRFQModal(false) }} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50"> <button disabled={!!actionLoading} onClick={async () => { await doAction('to-rfq', 'POST', { assignee: modalAssignee, note: modalNote }); setShowRFQModal(false) }} className="flex-1 py-2 bg-secondary text-white rounded-lg text-xs font-semibold hover:opacity-90 disabled:opacity-50">
{actionLoading === 'to-rfq' ? '处理中...' : '确认转询价'} {actionLoading === 'to-rfq' ? '处理中...' : '确认转询价'}
</button> </button>
<button disabled={!!actionLoading} onClick={() => setShowRFQModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button> <button disabled={!!actionLoading} onClick={() => setShowRFQModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
</> </div>
} </div>
> </div>
<div className="space-y-4"> </div>
)}
{/* 转协同 Modal */}
{showCollabModal && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!actionLoading) setShowCollabModal(false) }}>
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">转为协同</h3>
<button disabled={!!actionLoading} onClick={() => setShowCollabModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<div> <div>
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span> <span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
<select value={modalAssignee} onChange={e => setModalAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20"> <select value={modalAssignee} onChange={e => setModalAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
@ -581,40 +608,22 @@ export default function LeadDetailPage({ leadId, onNavigate }: { leadId: string;
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span> <span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
<textarea value={modalNote} onChange={e => setModalNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" /> <textarea value={modalNote} onChange={e => setModalNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
</div> </div>
</div> <div className="flex gap-3 pt-2">
</Modal>
{/* 转协同 Modal */}
<Modal
open={showCollabModal}
onClose={() => setShowCollabModal(false)}
title="转为协同"
width="w-[480px]"
loading={!!actionLoading}
footer={
<>
<button disabled={!!actionLoading} onClick={async () => { await doAction('to-collab', 'POST', { assignee: modalAssignee, note: modalNote }); setShowCollabModal(false) }} className="flex-1 py-2 bg-white border border-purple-200 text-purple-700 rounded-lg text-xs font-semibold hover:bg-purple-50 disabled:opacity-50"> <button disabled={!!actionLoading} onClick={async () => { await doAction('to-collab', 'POST', { assignee: modalAssignee, note: modalNote }); setShowCollabModal(false) }} className="flex-1 py-2 bg-white border border-purple-200 text-purple-700 rounded-lg text-xs font-semibold hover:bg-purple-50 disabled:opacity-50">
{actionLoading === 'to-collab' ? '处理中...' : '确认转协同'} {actionLoading === 'to-collab' ? '处理中...' : '确认转协同'}
</button> </button>
<button disabled={!!actionLoading} onClick={() => setShowCollabModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button> <button disabled={!!actionLoading} onClick={() => setShowCollabModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs hover:bg-slate-50">取消</button>
</>
}
>
<div className="space-y-4">
<div>
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
<select value={modalAssignee} onChange={e => setModalAssignee(e.target.value)} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-secondary/20">
<option value="郑钊">郑钊</option>
<option value="赵主任">赵主任</option>
<option value="运营管理员">运营管理员</option>
</select>
</div>
<div>
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
<textarea value={modalNote} onChange={e => setModalNote(e.target.value)} className="w-full border border-border-subtle rounded-lg p-2.5 text-sm bg-white resize-none outline-none focus:ring-2 focus:ring-secondary/20" rows={2} placeholder="可选备注信息" />
</div> </div>
</div> </div>
</Modal> </div>
</div>
)}
{toast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-5 py-2.5 rounded-lg text-sm shadow-lg z-50">
{toast}
</div>
)}
</div> </div>
) )
} }

View File

@ -2,7 +2,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useAuth } from './AuthProvider' import { useAuth } from './AuthProvider'
import IcpFooter from './IcpFooter'
export default function LoginPage() { export default function LoginPage() {
const { login } = useAuth() const { login } = useAuth()
@ -25,7 +24,7 @@ export default function LoginPage() {
} }
return ( return (
<div className="min-h-screen bg-[#f7f9fb] relative flex items-center justify-center"> <div className="min-h-screen bg-[#f7f9fb] flex items-center justify-center">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-sm border border-border-subtle p-10"> <div className="bg-white rounded-2xl shadow-sm border border-border-subtle p-10">
<div className="text-center mb-10"> <div className="text-center mb-10">
@ -83,9 +82,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="absolute bottom-0 left-0 right-0">
<IcpFooter />
</div>
</div> </div>
) )
} }

View File

@ -346,14 +346,13 @@ export default function OverviewPage({ onNavigate }: OverviewPageProps) {
<tbody className="divide-y divide-border-subtle"> <tbody className="divide-y divide-border-subtle">
{health.length === 0 ? ( {health.length === 0 ? (
<tr> <tr>
<td colSpan={4} className="px-5 py-8 text-center"> <td colSpan={4} className="px-5 py-8 text-center text-sm text-on-surface-variant">
<div className="flex flex-col items-center gap-3"> <div className="flex items-center justify-center gap-2">
<div className="flex gap-1"> <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<div className="w-2 h-2 rounded-full bg-secondary animate-bounce" style={{ animationDelay: '0ms' }} /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<div className="w-2 h-2 rounded-full bg-secondary animate-bounce" style={{ animationDelay: '150ms' }} /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<div className="w-2 h-2 rounded-full bg-secondary animate-bounce" style={{ animationDelay: '300ms' }} /> </svg>
</div> 正在连接后端服务...
<span className="text-xs text-on-surface-variant">正在连接后端服务...</span>
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -2,10 +2,6 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { API_BASE } from '@/lib/apiBase' import { API_BASE } from '@/lib/apiBase'
import { useToast } from '@/components/ui/Toast'
import Lightbox from '@/components/ui/Lightbox'
import Modal from '@/components/ui/Modal'
import { SkeletonTable } from '@/components/ui/Skeleton'
interface Product { interface Product {
id: string id: string
@ -59,9 +55,9 @@ export default function ProductPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showImport, setShowImport] = useState(false) const [showImport, setShowImport] = useState(false)
const [showTemplate, setShowTemplate] = useState(false) const [showTemplate, setShowTemplate] = useState(false)
const [toast, setToast] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [stats, setStats] = useState({ total: 0, pending: 0, published: 0, failed: 0 }) const [stats, setStats] = useState({ total: 0, pending: 0, published: 0, failed: 0 })
const { showToast } = useToast()
// File import state // File import state
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
@ -130,6 +126,11 @@ export default function ProductPage() {
const totalPages = Math.ceil(filtered.length / pageSize) || 1 const totalPages = Math.ceil(filtered.length / pageSize) || 1
const paged = filtered.slice((page - 1) * pageSize, page * pageSize) const paged = filtered.slice((page - 1) * pageSize, page * pageSize)
const showToast = (msg: string) => {
setToast(msg)
setTimeout(() => setToast(''), 2500)
}
const filterByStatus = (status: string) => { const filterByStatus = (status: string) => {
setCurrentFilter(status) setCurrentFilter(status)
setPage(1) setPage(1)
@ -171,20 +172,14 @@ export default function ProductPage() {
const batchSync = async () => { const batchSync = async () => {
if (selectedIds.size === 0) return if (selectedIds.size === 0) return
showToast(`正在同步 ${selectedIds.size} 个商品...`) showToast(`正在同步 ${selectedIds.size} 个商品...`)
try { await fetch(`${API}/batch-publish`, {
const res = await fetch(`${API}/batch-publish`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: Array.from(selectedIds) }), body: JSON.stringify({ ids: Array.from(selectedIds) }),
}) })
const data = await res.json()
if (!res.ok) throw new Error(data.message || data.error || '请求失败')
setSelectedIds(new Set()) setSelectedIds(new Set())
await loadProducts() await loadProducts()
showToast(`同步成功: ${data.imported || 0} 条导入 B2B${data.errors?.length ? `, 失败 ${data.errors.length} 条` : ''}`) showToast('批量同步完成')
} catch (e: any) {
showToast(`同步失败: ${e.message}`)
}
} }
const removeProduct = async (id: string) => { const removeProduct = async (id: string) => {
@ -399,7 +394,7 @@ export default function ProductPage() {
{ key: 'failed', label: '同步失败', value: stats.failed, color: 'text-rose-600', suffix: '条同步失败', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z' }, { key: 'failed', label: '同步失败', value: stats.failed, color: 'text-rose-600', suffix: '条同步失败', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z' },
] ]
if (loading) return <SkeletonTable rows={8} cols={8} /> if (loading) return <div className="text-center py-20 text-on-surface-variant">加载中...</div>
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -594,24 +589,19 @@ export default function ProductPage() {
</div> </div>
{/* Import Modal */} {/* Import Modal */}
<Modal {showImport && (
open={showImport} <div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!importing) setShowImport(false) }}>
onClose={() => setShowImport(false)} <div className="bg-white rounded-xl shadow-2xl w-[680px] max-h-[calc(100vh-100px)] overflow-y-auto transform" onClick={e => e.stopPropagation()}>
title="导入商品" <div className="p-5 border-b border-border-subtle flex justify-between items-center">
subtitle="支持 .xlsx, .xls, .csv 格式" <div>
width="w-[680px]" <h2 className="text-base font-semibold">导入商品</h2>
loading={importing} <p className="text-[10px] text-slate-500 mt-1">支持 .xlsx, .xls, .csv 格式</p>
footer={ </div>
<> <button disabled={importing} onClick={() => setShowImport(false)} className="p-2 hover:bg-slate-100 rounded-full">
<button disabled={importing} onClick={() => setShowImport(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs">取消</button> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
<button disabled={!importPreview || importing} onClick={confirmImport}
className="px-4 py-2 bg-secondary text-white rounded-lg text-xs font-medium disabled:opacity-50 hover:opacity-90">
{importing ? '导入中...' : '确认导入'}
</button> </button>
</> </div>
} <div className="p-5 space-y-4">
>
<div className="space-y-4">
{/* Upload Zone */} {/* Upload Zone */}
{!importFile && ( {!importFile && (
<div <div
@ -672,21 +662,52 @@ export default function ProductPage() {
<div className="mt-2 text-[10px] text-slate-500">共 {importPreview.length} 条数据将导入</div> <div className="mt-2 text-[10px] text-slate-500">共 {importPreview.length} 条数据将导入</div>
</div> </div>
)} )}
<div className="flex justify-end gap-3 pt-4 border-t border-border-subtle">
<button disabled={importing} onClick={() => setShowImport(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-xs">取消</button>
<button disabled={!importPreview || importing} onClick={confirmImport}
className="px-4 py-2 bg-secondary text-white rounded-lg text-xs font-medium disabled:opacity-50 hover:opacity-90">
{importing ? '导入中...' : '确认导入'}
</button>
</div> </div>
</Modal> </div>
</div>
</div>
)}
{/* Template Modal */} {/* Template Modal */}
<Modal open={showTemplate} onClose={() => setShowTemplate(false)} title="商品导入模板说明" width="w-[480px]"> {showTemplate && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowTemplate(false)}>
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">商品导入模板说明</h3>
<button onClick={() => setShowTemplate(false)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5">
<p className="text-xs text-slate-600 mb-3">请按以下列顺序准备Excel文件:</p> <p className="text-xs text-slate-600 mb-3">请按以下列顺序准备Excel文件:</p>
<div className="grid grid-cols-2 gap-2 text-[10px]"> <div className="grid grid-cols-2 gap-2 text-[10px]">
{['商品编号', 'SKU编号', '商品标题', '品牌', '一级分类', '二级分类', '规格型号', '单位', '原价', '现价', '税率'].map((c, i) => ( {['商品编号', 'SKU编号', '商品标题', '品牌', '一级分类', '二级分类', '规格型号', '单位', '原价', '现价', '税率'].map((c, i) => (
<div key={c} className="p-2 bg-slate-50 rounded"><span className="font-medium">{i + 1}.</span> {c}</div> <div key={c} className="p-2 bg-slate-50 rounded"><span className="font-medium">{i + 1}.</span> {c}</div>
))} ))}
</div> </div>
</Modal> </div>
</div>
</div>
)}
{/* Preview Lightbox */} {/* Preview Lightbox */}
<Lightbox src={previewUrl} onClose={() => setPreviewUrl(null)} /> {previewUrl && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-8" onClick={() => setPreviewUrl(null)}>
<div className="relative max-w-[90vw] max-h-[90vh]" onClick={e => e.stopPropagation()}>
<img src={previewUrl} alt="预览" className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl" />
<button onClick={() => setPreviewUrl(null)} className="absolute -top-3 -right-3 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-slate-100">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
)}
{/* 浮动生图进度指示器 */} {/* 浮动生图进度指示器 */}
{genCount.total > 0 && ( {genCount.total > 0 && (
@ -706,6 +727,13 @@ export default function ProductPage() {
{/* Hidden image upload input */} {/* Hidden image upload input */}
<input ref={imageUploadRef} type="file" accept="image/*" className="hidden" onChange={handleImageSelected} /> <input ref={imageUploadRef} type="file" accept="image/*" className="hidden" onChange={handleImageSelected} />
{/* Toast */}
{toast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-6 py-3 rounded-lg text-xs shadow-lg z-50">
{toast}
</div>
)}
</div> </div>
) )
} }

View File

@ -11,6 +11,15 @@ interface WeightItem {
max: number max: number
} }
interface Keyword {
id: number
word: string
category: string
weight: number
matchCount: number
enabled: boolean
}
interface ExampleLead { interface ExampleLead {
name: string name: string
amount: string amount: string
@ -62,6 +71,26 @@ const EXAMPLE_LEADS: ExampleLead[] = [
{ name: '信阳乡村振兴供水项目管材采购', amount: '65万', score: 45 }, { name: '信阳乡村振兴供水项目管材采购', amount: '65万', score: 45 },
] ]
const CATEGORIES = ['工程建材', '医疗器械', 'IT设备', '办公用品', '物流服务', '咨询服务']
const MOCK_KEYWORDS: Keyword[] = [
{ id: 1, word: '防火门', category: '工程建材', weight: 9, matchCount: 47, enabled: true },
{ id: 2, word: '配电箱', category: '工程建材', weight: 8, matchCount: 35, enabled: true },
{ id: 3, word: '电线电缆', category: '工程建材', weight: 8, matchCount: 42, enabled: true },
{ id: 4, word: '阀门', category: '工程建材', weight: 7, matchCount: 28, enabled: true },
{ id: 5, word: '钢材', category: '工程建材', weight: 9, matchCount: 56, enabled: true },
{ id: 6, word: '混凝土', category: '工程建材', weight: 7, matchCount: 33, enabled: true },
{ id: 7, word: 'CT设备', category: '医疗器械', weight: 8, matchCount: 12, enabled: true },
{ id: 8, word: 'MRI核磁共振', category: '医疗器械', weight: 9, matchCount: 8, enabled: true },
{ id: 9, word: '服务器', category: 'IT设备', weight: 7, matchCount: 15, enabled: true },
{ id: 10, word: '交换机', category: 'IT设备', weight: 6, matchCount: 11, enabled: true },
{ id: 11, word: '办公桌椅', category: '办公用品', weight: 4, matchCount: 23, enabled: false },
{ id: 12, word: '打印设备', category: '办公用品', weight: 5, matchCount: 19, enabled: true },
{ id: 13, word: '物流运输', category: '物流服务', weight: 6, matchCount: 31, enabled: true },
{ id: 14, word: '仓储服务', category: '物流服务', weight: 5, matchCount: 14, enabled: true },
{ id: 15, word: '管理咨询', category: '咨询服务', weight: 4, matchCount: 9, enabled: false },
]
const PERSONS = ['张三', '李四', '王五', '赵六', '钱七'] const PERSONS = ['张三', '李四', '王五', '赵六', '钱七']
const DEFAULT_DISTRIBUTION: DistributeRule[] = [ const DEFAULT_DISTRIBUTION: DistributeRule[] = [
@ -93,9 +122,10 @@ const TYPE_OPTIONS = [
{ value: 'bid_cnncecp', label: '中核集团招采' }, { value: 'bid_cnncecp', label: '中核集团招采' },
] ]
const CRON_PRESETS = [ const CRON_PRESETS = [
{ label: '每 6 个小时', value: '0 0 */6 * * *' }, { label: '每 4 个小时', value: '0 */4 * * *' },
{ label: '每天早 8 点', value: '0 0 8 * * *' }, { label: '每 6 个小时', value: '0 */6 * * *' },
{ label: '每天晚 7 点', value: '0 0 19 * * *' }, { label: '每天早 8 点', value: '0 8 * * *' },
{ label: '每天晚 7 点', value: '0 19 * * *' },
{ label: '手动(不自动执行)', value: '' }, { label: '手动(不自动执行)', value: '' },
] ]
@ -113,16 +143,17 @@ function getLevelClass(level: string) {
export default function RuleCenterPage() { export default function RuleCenterPage() {
const [activeTab, setActiveTab] = useState<'weights' | 'keywords' | 'client' | 'crawlers'>('weights') const [activeTab, setActiveTab] = useState<'weights' | 'keywords' | 'client' | 'crawlers'>('weights')
const [weights, setWeights] = useState<WeightItem[]>(DEFAULT_WEIGHTS) const [weights, setWeights] = useState<WeightItem[]>(DEFAULT_WEIGHTS)
const [keywords, setKeywords] = useState<Keyword[]>(MOCK_KEYWORDS)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
// 品类管理 const [nextKeywordId, setNextKeywordId] = useState(16)
const [categories, setCategories] = useState<any[]>([])
const [catLoading, setCatLoading] = useState(true) const [newWord, setNewWord] = useState('')
const [showAddCat, setShowAddCat] = useState(false) const [newCategory, setNewCategory] = useState(CATEGORIES[0])
const [addCatName, setAddCatName] = useState('') const [newWeight, setNewWeight] = useState(5)
const [addCatKeywords, setAddCatKeywords] = useState('')
const [editCatId, setEditCatId] = useState<string | null>(null) const [showBatchModal, setShowBatchModal] = useState(false)
const [editCatName, setEditCatName] = useState('') const [batchText, setBatchText] = useState('')
const [editCatKeywords, setEditCatKeywords] = useState('') const [showClearModal, setShowClearModal] = useState(false)
const [sAmount, setSAmount] = useState(100) const [sAmount, setSAmount] = useState(100)
const [sCoop, setSCoop] = useState(3) const [sCoop, setSCoop] = useState(3)
@ -138,9 +169,9 @@ export default function RuleCenterPage() {
const [crawlers, setCrawlers] = useState<CrawlerConfig[]>([]) const [crawlers, setCrawlers] = useState<CrawlerConfig[]>([])
const [crawlersLoading, setCrawlersLoading] = useState(true) const [crawlersLoading, setCrawlersLoading] = useState(true)
const [editCrawlerId, setEditCrawlerId] = useState<string | null>(null) const [editCrawlerId, setEditCrawlerId] = useState<string | null>(null)
const [editForm, setEditForm] = useState<{ name: string; targetUrl: string; type: string; sourceName: string; scriptPath: string; schedule: string; keywords: string; categories: string }>({ name: '', targetUrl: '', type: 'bid', sourceName: '', scriptPath: '', schedule: '', keywords: '', categories: '[]' }) const [editForm, setEditForm] = useState<{ name: string; targetUrl: string; type: string; sourceName: string; scriptPath: string; schedule: string; keywords: string; categories: string }>({ name: '', targetUrl: '', type: 'bid', sourceName: '', scriptPath: '', schedule: '', keywords: '', categories: '' })
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const [addForm, setAddForm] = useState<{ name: string; targetUrl: string; type: string; sourceName: string; scriptPath: string; schedule: string; keywords: string; categories: string }>({ name: '', targetUrl: '', type: 'bid', sourceName: '', scriptPath: '', schedule: '0 */6 * * *', keywords: '', categories: '[]' }) const [addForm, setAddForm] = useState<{ name: string; targetUrl: string; type: string; sourceName: string; scriptPath: string; schedule: string; keywords: string; categories: string }>({ name: '', targetUrl: '', type: 'bid', sourceName: '', scriptPath: '', schedule: '0 */6 * * *', keywords: '', categories: '' })
const [runModal, setRunModal] = useState<{ id: string; name: string } | null>(null) const [runModal, setRunModal] = useState<{ id: string; name: string } | null>(null)
const [runResult, setRunResult] = useState<'running' | 'success' | 'error'>('running') const [runResult, setRunResult] = useState<'running' | 'success' | 'error'>('running')
@ -201,67 +232,53 @@ export default function RuleCenterPage() {
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
} }
// 品类管理 - 获取列表 const handleAddKeyword = () => {
const fetchCategories = () => { if (!newWord.trim()) return
setCatLoading(true) setKeywords(prev => [
fetch(`${API_BASE}/api/categories`) ...prev,
.then(r => r.json()) { id: nextKeywordId, word: newWord.trim(), category: newCategory, weight: newWeight, matchCount: 0, enabled: true },
.then(data => setCategories(data)) ])
.catch(() => {}) setNextKeywordId(prev => prev + 1)
.finally(() => setCatLoading(false)) setNewWord('')
setNewWeight(5)
} }
// 品类管理 - 添加 const toggleKeyword = (id: number) => {
const handleAddCategory = async () => { setKeywords(prev => prev.map(k => k.id === id ? { ...k, enabled: !k.enabled } : k))
if (!addCatName.trim()) return
const kwArr = addCatKeywords.split(/[,,、\s]+/).filter(Boolean)
try {
const res = await fetch(`${API_BASE}/api/categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: addCatName.trim(), keywords: kwArr }),
})
if (!res.ok) throw new Error()
const created = await res.json()
setCategories(prev => [...prev, created])
setShowAddCat(false)
setAddCatName('')
setAddCatKeywords('')
} catch { alert('添加品类失败') }
} }
// 品类管理 - 编辑 const deleteKeyword = (id: number) => {
const startEditCat = (cat: any) => { setKeywords(prev => prev.filter(k => k.id !== id))
setEditCatId(cat.id)
setEditCatName(cat.name)
setEditCatKeywords((() => {
try { return (JSON.parse(cat.keywords) as string[]).join(', ') } catch { return cat.keywords || '' }
})())
} }
const handleUpdateCategory = async () => { const editKeyword = (id: number) => {
if (!editCatId || !editCatName.trim()) return const kw = keywords.find(k => k.id === id)
const kwArr = editCatKeywords.split(/[,,、\s]+/).filter(Boolean) if (!kw) return
try { setNewWord(kw.word)
const res = await fetch(`${API_BASE}/api/categories/${editCatId}`, { setNewCategory(kw.category)
method: 'PUT', setNewWeight(kw.weight)
headers: { 'Content-Type': 'application/json' }, setKeywords(prev => prev.filter(k => k.id !== id))
body: JSON.stringify({ name: editCatName.trim(), keywords: kwArr }),
})
if (!res.ok) throw new Error()
const updated = await res.json()
setCategories(prev => prev.map(c => c.id === editCatId ? updated : c))
setEditCatId(null)
} catch { alert('更新品类失败') }
} }
// 品类管理 - 删除 const handleBatchImport = () => {
const handleDeleteCategory = async (id: string, name: string) => { const lines = batchText.split('\n').map(l => l.trim()).filter(Boolean)
if (!confirm(`确定要删除品类「${name}」吗?已有线索的该品类不受影响。`)) return const newKeywords: Keyword[] = lines.map((line, i) => ({
try { id: nextKeywordId + i,
await fetch(`${API_BASE}/api/categories/${id}`, { method: 'DELETE' }) word: line,
setCategories(prev => prev.filter(c => c.id !== id)) category: '工程建材',
} catch { alert('删除品类失败') } weight: 5,
matchCount: 0,
enabled: true,
}))
setKeywords(prev => [...prev, ...newKeywords])
setNextKeywordId(prev => prev + lines.length)
setBatchText('')
setShowBatchModal(false)
}
const handleClearAll = () => {
setKeywords([])
setShowClearModal(false)
} }
const handleDistributeChange = (level: string, person: string) => { const handleDistributeChange = (level: string, person: string) => {
@ -278,9 +295,6 @@ export default function RuleCenterPage() {
.finally(() => setCrawlersLoading(false)) .finally(() => setCrawlersLoading(false))
}, []) }, [])
// 初次加载品类列表
useEffect(() => { fetchCategories() }, [])
const startEditCrawler = (c: CrawlerConfig) => { const startEditCrawler = (c: CrawlerConfig) => {
setEditCrawlerId(c.id) setEditCrawlerId(c.id)
setEditForm({ setEditForm({
@ -290,17 +304,18 @@ export default function RuleCenterPage() {
sourceName: c.sourceName || '', sourceName: c.sourceName || '',
scriptPath: c.scriptPath || '', scriptPath: c.scriptPath || '',
schedule: c.schedule || '', schedule: c.schedule || '',
keywords: '', keywords: c.keywords ? (() => { try { return JSON.parse(c.keywords).join(', ') } catch { return c.keywords } })() : '',
categories: c.categories ? (() => { try { const p = JSON.parse(c.categories); return Array.isArray(p) ? JSON.stringify(p) : '[]' } catch { return '[]' } })() : '[]', categories: c.categories ? (() => { try { return JSON.parse(c.categories).join(', ') } catch { return c.categories } })() : '',
}) })
} }
const saveCrawler = async (id: string) => { const saveCrawler = async (id: string) => {
const payload: Record<string, string | null> = { name: editForm.name, targetUrl: editForm.targetUrl, type: editForm.type, sourceName: editForm.sourceName || null, scriptPath: editForm.scriptPath || null } const payload: Record<string, string | null> = { name: editForm.name, targetUrl: editForm.targetUrl, type: editForm.type, sourceName: editForm.sourceName || null, scriptPath: editForm.scriptPath || null }
if (editForm.schedule) payload.schedule = editForm.schedule if (editForm.schedule) payload.schedule = editForm.schedule
const catArr: string[] = editForm.categories ? JSON.parse(editForm.categories) : [] const kwArr = editForm.keywords.split(/[,,、\s]+/).filter(Boolean)
payload.categories = catArr.length > 0 ? JSON.stringify(catArr) : null payload.keywords = kwArr.length > 0 ? JSON.stringify(kwArr) : ''
// 新配置不再使用 keywords 字段,但不清理旧数据(resolveKeywords 有 fallback) const catArr = editForm.categories.split(/[,,、\s]+/).filter(Boolean)
payload.categories = catArr.length > 0 ? JSON.stringify(catArr) : ''
try { try {
const res = await fetch(`${CRAWLER_API}/${id}`, { const res = await fetch(`${CRAWLER_API}/${id}`, {
method: 'PUT', method: 'PUT',
@ -333,7 +348,8 @@ export default function RuleCenterPage() {
const addCrawler = async () => { const addCrawler = async () => {
if (!addForm.name.trim() || !addForm.targetUrl.trim()) return if (!addForm.name.trim() || !addForm.targetUrl.trim()) return
const catArr: string[] = addForm.categories ? JSON.parse(addForm.categories) : [] const kwArr = addForm.keywords.split(/[,,、\s]+/).filter(Boolean)
const catArr = addForm.categories.split(/[,,、\s]+/).filter(Boolean)
try { try {
const res = await fetch(CRAWLER_API, { const res = await fetch(CRAWLER_API, {
method: 'POST', method: 'POST',
@ -345,7 +361,7 @@ export default function RuleCenterPage() {
sourceName: addForm.sourceName || null, sourceName: addForm.sourceName || null,
scriptPath: addForm.scriptPath || null, scriptPath: addForm.scriptPath || null,
schedule: addForm.schedule || null, schedule: addForm.schedule || null,
keywords: null, keywords: kwArr.length > 0 ? JSON.stringify(kwArr) : null,
categories: catArr.length > 0 ? JSON.stringify(catArr) : null, categories: catArr.length > 0 ? JSON.stringify(catArr) : null,
}), }),
}) })
@ -353,7 +369,7 @@ export default function RuleCenterPage() {
const created: CrawlerConfig = await res.json() const created: CrawlerConfig = await res.json()
setCrawlers(prev => [...prev, created]) setCrawlers(prev => [...prev, created])
setShowAddModal(false) setShowAddModal(false)
setAddForm({ name: '', targetUrl: '', type: 'bid', sourceName: '', scriptPath: '', schedule: '0 */6 * * *', keywords: '', categories: '[]' }) setAddForm({ name: '', targetUrl: '', type: 'bid', sourceName: '', scriptPath: '', schedule: '0 */6 * * *', keywords: '', categories: '' })
} catch { alert('创建失败') } } catch { alert('创建失败') }
} }
@ -517,7 +533,7 @@ export default function RuleCenterPage() {
<div className="p-4 rounded-lg bg-slate-50 border border-border-subtle"> <div className="p-4 rounded-lg bg-slate-50 border border-border-subtle">
<div className="text-xs text-on-surface-variant">待重算线索</div> <div className="text-xs text-on-surface-variant">待重算线索</div>
<div className="text-2xl font-bold mt-1"> <div className="text-2xl font-bold mt-1">
{'1,248'} {keywords.length > 0 ? '1,248' : '1,248'}
</div> </div>
</div> </div>
<div className="p-4 rounded-lg bg-emerald-50 border border-emerald-200"> <div className="p-4 rounded-lg bg-emerald-50 border border-emerald-200">
@ -572,141 +588,127 @@ export default function RuleCenterPage() {
{/* Tab 2: 关键词库管理 */} {/* Tab 2: 关键词库管理 */}
{activeTab === 'keywords' && ( {activeTab === 'keywords' && (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Add Keyword Row */}
<div className="flex justify-between items-center"> <div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
<p className="text-sm text-on-surface-variant"> <h3 className="text-base font-semibold mb-4">添加关键词</h3>
管理所属品类及其关键词。采集公告时优先按关键词匹配到对应品类,匹配不到则自动 AI 推断。 <div className="flex items-end gap-3">
</p> <div className="flex-1">
<button <span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">关键词</span>
onClick={() => setShowAddCat(true)}
className="px-4 py-2 bg-secondary text-white rounded-lg text-sm font-semibold flex items-center gap-2 hover:opacity-90 shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
添加品类
</button>
</div>
{/* Add Category Modal */}
{showAddCat && (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/30" onClick={() => setShowAddCat(false)}>
<div className="bg-white rounded-xl shadow-2xl w-[500px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">添加所属品类</h3>
<button onClick={() => setShowAddCat(false)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<div>
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">品类名称</span>
<input <input
value={addCatName} type="text"
onChange={e => setAddCatName(e.target.value)} value={newWord}
placeholder="如:彩板房" onChange={e => setNewWord(e.target.value)}
className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-secondary/20" placeholder="输入关键词"
className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none"
/> />
</div> </div>
<div> <div className="w-36">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">关键词列表(逗号分隔)</span> <span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">所属品类</span>
<textarea <select
value={addCatKeywords} value={newCategory}
onChange={e => setAddCatKeywords(e.target.value)} onChange={e => setNewCategory(e.target.value)}
placeholder="彩板房, 彩钢板, 夹芯板" className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none"
rows={3} >
className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-secondary/20 resize-none" {CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div className="w-24">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">权重 (1-10)</span>
<input
type="number"
min={1}
max={10}
value={newWeight}
onChange={e => setNewWeight(Math.max(1, Math.min(10, parseInt(e.target.value) || 1)))}
className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none"
/> />
<p className="text-[10px] text-on-surface-variant mt-1">公告标题包含任一关键词即自动归入该品类</p>
</div> </div>
<div className="flex gap-3 pt-2"> <button
<button onClick={handleAddCategory} disabled={!addCatName.trim()} className="flex-1 py-2 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-50">确认添加</button> onClick={handleAddKeyword}
<button onClick={() => setShowAddCat(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-sm hover:bg-slate-50">取消</button> disabled={!newWord.trim()}
className="px-4 py-2 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-40 shadow-sm"
>
+ 添加
</button>
</div> </div>
</div> </div>
</div>
</div>
)}
{/* Category Table */} {/* Keyword Table */}
<div className="bg-white rounded-xl border border-border-subtle shadow-sm overflow-hidden"> <div className="bg-white rounded-xl border border-border-subtle shadow-sm overflow-hidden">
{catLoading ? ( <div className="p-4 border-b border-border-subtle flex justify-between items-center">
<div className="p-8 text-center text-sm text-on-surface-variant">加载中...</div> <h3 className="text-base font-semibold">关键词列表</h3>
) : categories.length === 0 ? ( <div className="flex gap-2">
<div className="p-8 text-center text-sm text-on-surface-variant">暂无品类,请点击右上角「添加品类」</div> <button
) : ( onClick={() => setShowBatchModal(true)}
className="px-3 py-1.5 bg-white border border-border-subtle text-on-surface-variant rounded-lg text-xs flex items-center gap-1.5 hover:bg-slate-50 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
批量导入
</button>
<button
onClick={() => setShowClearModal(true)}
className="px-3 py-1.5 bg-white border border-border-subtle text-rose-600 rounded-lg text-xs flex items-center gap-1.5 hover:bg-rose-50 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
一键清空
</button>
</div>
</div>
<table className="w-full text-left"> <table className="w-full text-left">
<thead className="bg-slate-50 border-b border-border-subtle"> <thead className="bg-slate-50 border-b border-border-subtle">
<tr> <tr>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">品类名称</th> <th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">关键词</th>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">关键词列表</th> <th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">所属品类</th>
<th className="p-4 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">来源</th> <th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right">权重</th>
<th className="p-4 w-40 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">操作</th> <th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right">匹配次数</th>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">启用状态</th>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">操作</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-subtle"> <tbody className="divide-y divide-border-subtle">
{categories.map(cat => { {keywords.length === 0 ? (
const kwList: string[] = (() => { try { return JSON.parse(cat.keywords) } catch { return [] } })() <tr>
return editCatId === cat.id ? ( <td colSpan={6} className="p-8 text-center text-sm text-on-surface-variant">暂无关键词,请添加</td>
<tr key={cat.id} className="bg-blue-50/30">
<td className="p-3">
<input value={editCatName} onChange={e => setEditCatName(e.target.value)} className="w-full border border-border-subtle rounded px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-secondary/20" />
</td>
<td className="p-3">
<input value={editCatKeywords} onChange={e => setEditCatKeywords(e.target.value)} placeholder="逗号分隔" className="w-full border border-border-subtle rounded px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-secondary/20" />
</td>
<td className="p-3">
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${cat.isAIGenerated ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'}`}>
{cat.isAIGenerated ? 'AI' : '手动'}
</span>
</td>
<td className="p-3">
<div className="flex gap-2">
<button onClick={handleUpdateCategory} className="px-3 py-1 text-[11px] bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-md font-semibold hover:bg-emerald-100">保存</button>
<button onClick={() => setEditCatId(null)} className="px-3 py-1 text-[11px] bg-white border border-border-subtle rounded-md text-slate-600 hover:bg-slate-50">取消</button>
</div>
</td>
</tr> </tr>
) : ( ) : (
<tr key={cat.id} className="hover:bg-slate-50 transition-colors"> keywords.map(kw => (
<td className="p-4 text-sm font-semibold">{cat.name}</td> <tr key={kw.id} className="hover:bg-slate-50 transition-colors">
<td className="p-4"> <td className="p-4 text-sm font-medium">{kw.word}</td>
<div className="flex flex-wrap gap-1 max-w-[500px]"> <td className="p-4 text-sm text-on-surface-variant">
{kwList.length === 0 ? ( <span className="px-2 py-0.5 bg-slate-100 text-on-surface-variant text-[10px] font-medium rounded">{kw.category}</span>
<span className="text-xs text-slate-400">无关键词</span>
) : (
kwList.map((kw: string, i: number) => (
<span key={i} className="px-1.5 py-0.5 bg-slate-100 text-slate-700 text-[10px] rounded whitespace-nowrap">{kw}</span>
))
)}
</div>
</td> </td>
<td className="p-4 text-sm text-right font-semibold">{kw.weight}</td>
<td className="p-4 text-sm text-right text-on-surface-variant">{kw.matchCount}</td>
<td className="p-4"> <td className="p-4">
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${cat.isAIGenerated ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'}`}> <button
{cat.isAIGenerated ? 'AI' : '手动'} onClick={() => toggleKeyword(kw.id)}
</span> className={`relative w-9 h-5 rounded-full transition-colors ${kw.enabled ? 'bg-secondary' : 'bg-slate-300'}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${kw.enabled ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</td> </td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => startEditCat(cat)} className="text-xs text-blue-600 hover:underline">编辑</button> <button onClick={() => editKeyword(kw.id)} className="text-xs text-blue-600 hover:underline">编辑</button>
<span className="text-slate-300">|</span> <span className="text-slate-300">|</span>
<button onClick={() => handleDeleteCategory(cat.id, cat.name)} className="text-xs text-rose-500 hover:underline">删除</button> <button onClick={() => deleteKeyword(kw.id)} className="text-xs text-rose-500 hover:underline">删除</button>
</div> </div>
</td> </td>
</tr> </tr>
) ))
})} )}
</tbody> </tbody>
</table> </table>
)}
{!catLoading && categories.length > 0 && (
<div className="bg-slate-50 px-4 py-2 border-t border-border-subtle flex justify-between items-center"> <div className="bg-slate-50 px-4 py-2 border-t border-border-subtle flex justify-between items-center">
<span className="text-xs text-on-surface-variant">共 {categories.length} 个品类</span> <span className="text-xs text-on-surface-variant">共 {keywords.length} 个关键词</span>
{keywords.length > 0 && (
<span className="text-[10px] text-on-surface-variant"> <span className="text-[10px] text-on-surface-variant">
手动 {categories.filter(c => !c.isAIGenerated).length} 个 · AI 创建 {categories.filter(c => c.isAIGenerated).length} 个 启用 {keywords.filter(k => k.enabled).length} 个 · 停用 {keywords.filter(k => !k.enabled).length} 个
</span> </span>
</div>
)} )}
</div> </div>
</div> </div>
</div>
)} )}
{/* Tab 4: 采集器配置 */} {/* Tab 4: 采集器配置 */}
@ -737,7 +739,7 @@ export default function RuleCenterPage() {
<th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">采集器名称</th> <th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">采集器名称</th>
<th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">目标网址</th> <th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">目标网址</th>
<th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">抓取定时</th> <th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">抓取定时</th>
<th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">关联品类</th> <th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">过滤关键词</th>
<th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">状态</th> <th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">状态</th>
<th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">运行统计</th> <th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">运行统计</th>
<th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">操作</th> <th className="p-3 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">操作</th>
@ -760,28 +762,7 @@ export default function RuleCenterPage() {
</select> </select>
</td> </td>
<td className="p-3"> <td className="p-3">
<div className="max-h-[150px] overflow-y-auto space-y-1 text-xs"> <input value={editForm.keywords} onChange={e => setEditForm(f => ({ ...f, keywords: e.target.value }))} placeholder="用逗号分隔" className="w-full border border-border-subtle rounded px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-secondary/20" />
{categories.length === 0 ? (
<span className="text-slate-400">请先在「关键词库管理」添加品类</span>
) : categories.map(cat => {
const current: string[] = editForm.categories ? JSON.parse(editForm.categories) : []
const selected = current.includes(cat.name)
return (
<label key={cat.id} className="flex items-center gap-1.5 cursor-pointer hover:bg-slate-50 rounded p-0.5">
<input
type="checkbox"
checked={selected}
onChange={() => {
const next = selected ? current.filter(n => n !== cat.name) : [...current, cat.name]
setEditForm(f => ({ ...f, categories: JSON.stringify(next) }))
}}
className="accent-secondary"
/>
{cat.name}
</label>
)
})}
</div>
</td> </td>
<td className="p-3"> <td className="p-3">
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full border ${getStatusStyle(c.status)}`}> <span className={`px-2 py-0.5 text-[10px] font-medium rounded-full border ${getStatusStyle(c.status)}`}>
@ -804,34 +785,23 @@ export default function RuleCenterPage() {
<td className="p-3 text-xs text-on-surface-variant max-w-[200px] truncate">{c.targetUrl}</td> <td className="p-3 text-xs text-on-surface-variant max-w-[200px] truncate">{c.targetUrl}</td>
<td className="p-3 text-xs text-on-surface-variant">{getCronLabel(c.schedule)}</td> <td className="p-3 text-xs text-on-surface-variant">{getCronLabel(c.schedule)}</td>
<td className="p-3"> <td className="p-3">
{c.categories ? ( {c.keywords ? (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{(JSON.parse(c.categories) as string[]).slice(0, 3).map((cat, i) => (
<span key={i} className="px-1.5 py-0.5 bg-purple-50 text-purple-600 text-[10px] rounded">{cat}</span>
))}
{(JSON.parse(c.categories) as string[]).length > 3 && (
<span className="text-[10px] text-slate-400">+{JSON.parse(c.categories).length - 3}</span>
)}
</div>
) : c.keywords ? (
<div className="flex flex-wrap gap-1 items-center">
{(JSON.parse(c.keywords) as string[]).slice(0, 3).map((kw, i) => ( {(JSON.parse(c.keywords) as string[]).slice(0, 3).map((kw, i) => (
<span key={i} className="px-1.5 py-0.5 bg-slate-100 text-slate-500 text-[10px] rounded">{kw}</span> <span key={i} className="px-1.5 py-0.5 bg-blue-50 text-blue-600 text-[10px] rounded">{kw}</span>
))} ))}
<span className="text-[10px] text-slate-400">(旧)</span> {(JSON.parse(c.keywords) as string[]).length > 3 && (
<span className="text-[10px] text-slate-400">+{JSON.parse(c.keywords).length - 3}</span>
)}
</div> </div>
) : <span className="text-xs text-slate-400">全部</span>} ) : <span className="text-xs text-slate-400">全部</span>}
</td> </td>
<td className="p-3"> <td className="p-3">
<button <button
onClick={() => toggleCrawlerStatus(c.id, c.status)} onClick={() => toggleCrawlerStatus(c.id, c.status)}
className={`px-3 py-1 text-[11px] font-semibold rounded-md transition-all cursor-pointer ${ className={`px-2 py-0.5 text-[10px] font-medium rounded-full border ${getStatusStyle(c.status)}`}
c.status === 'active'
? 'bg-amber-50 text-amber-700 hover:bg-amber-100 active:bg-amber-200'
: 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100 active:bg-emerald-200'
}`}
> >
{{ active: '点击暂停', paused: '点击启动', error: '重启' }[c.status] || c.status} {{ active: '暂停', paused: '启动', error: '重启' }[c.status] || c.status}
</button> </button>
</td> </td>
<td className="p-3 text-xs text-on-surface-variant"> <td className="p-3 text-xs text-on-surface-variant">
@ -1076,6 +1046,34 @@ export default function RuleCenterPage() {
</div> </div>
)} )}
{/* Batch Import Modal */}
{showBatchModal && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowBatchModal(false)}>
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">批量导入关键词</h3>
<button onClick={() => setShowBatchModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-sm text-on-surface-variant">每行一个关键词,将自动归入默认品类(可后续编辑)</p>
<textarea
value={batchText}
onChange={e => setBatchText(e.target.value)}
placeholder={"防火门\n配电箱\n电线电缆\n阀门\n钢材"}
rows={8}
className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none resize-none"
/>
<div className="flex gap-3 pt-2">
<button onClick={handleBatchImport} disabled={!batchText.trim()} className="flex-1 py-2 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-50">确认导入</button>
<button onClick={() => setShowBatchModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-sm hover:bg-slate-50">取消</button>
</div>
</div>
</div>
</div>
)}
{/* Add Crawler Modal */} {/* Add Crawler Modal */}
{showAddModal && ( {showAddModal && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowAddModal(false)}> <div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowAddModal(false)}>
@ -1111,34 +1109,12 @@ export default function RuleCenterPage() {
</select> </select>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-2">关联品类(选中的品类关键词用于过滤公告)</span> <span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">过滤关键词(逗号分隔,留空表示抓取全部)</span>
<div className="max-h-[200px] overflow-y-auto border border-border-subtle rounded-lg p-3 space-y-2"> <input value={addForm.keywords} onChange={e => setAddForm(f => ({ ...f, keywords: e.target.value }))} placeholder="电缆, 防火门, 配电箱" className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-secondary/20" />
{categories.length === 0 ? (
<p className="text-xs text-on-surface-variant">暂无品类,请先在「关键词库管理」中添加品类</p>
) : categories.map(cat => {
const current: string[] = addForm.categories ? JSON.parse(addForm.categories) : []
const selected = current.includes(cat.name)
return (
<label key={cat.id} className="flex items-center gap-2 cursor-pointer hover:bg-slate-50 p-1 rounded">
<input
type="checkbox"
checked={selected}
onChange={() => {
const next = selected ? current.filter(n => n !== cat.name) : [...current, cat.name]
setAddForm(f => ({ ...f, categories: JSON.stringify(next) }))
}}
className="accent-secondary"
/>
<span className="text-sm">{cat.name}</span>
{(() => { try { const k = JSON.parse(cat.keywords); return Array.isArray(k) ? k : [] } catch { return [] } })().length > 0 && (
<span className="text-[10px] text-on-surface-variant">
({(() => { try { const k = JSON.parse(cat.keywords); return Array.isArray(k) ? k.slice(0, 3).join(', ') : '' } catch { return '' } })()})
</span>
)}
</label>
)
})}
</div> </div>
<div className="col-span-2">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">所属品类(逗号分隔,留空表示全部品类)</span>
<input value={addForm.categories} onChange={e => setAddForm(f => ({ ...f, categories: e.target.value }))} placeholder="工程建材, 电力设备, 钢材" className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-secondary/20" />
</div> </div>
</div> </div>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
@ -1198,6 +1174,29 @@ export default function RuleCenterPage() {
</div> </div>
)} )}
{/* Clear All Modal */}
{showClearModal && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowClearModal(false)}>
<div className="bg-white rounded-xl shadow-2xl w-[400px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold text-rose-600">确认清空</h3>
<button onClick={() => setShowClearModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<div className="flex items-start gap-3 bg-rose-50 p-3 rounded-lg">
<svg className="w-5 h-5 text-rose-600 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-7v2h2v-2h-2zm0-8v6h2V7h-2z" /></svg>
<p className="text-sm">确定要清空所有关键词吗?此操作不可撤销,共 <b>{keywords.length}</b> 个关键词将被删除。</p>
</div>
<div className="flex gap-3 pt-2">
<button onClick={handleClearAll} className="flex-1 py-2 bg-rose-600 text-white rounded-lg text-sm font-semibold hover:bg-rose-700">确认清空</button>
<button onClick={() => setShowClearModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-sm hover:bg-slate-50">取消</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -11,6 +11,13 @@ interface User {
role: string role: string
} }
const sceneGroups: Record<string, { label: string; icon: string; pages: Page[] }> = {
inquiry: { label: '询价发布助手', icon: '📋', pages: ['inquiry', 'lead-detail'] },
product: { label: '商品上架助手', icon: '📦', pages: ['product'] },
image: { label: '商品生图助手', icon: '🖼️', pages: ['image'] },
comparison: { label: '智能比价助手', icon: '⚖️', pages: ['comparison'] },
}
const adminPages: { key: Page; label: string }[] = [ const adminPages: { key: Page; label: string }[] = [
{ key: 'admin-tenants', label: '租户管理' }, { key: 'admin-tenants', label: '租户管理' },
{ key: 'admin-stats', label: '使用统计' }, { key: 'admin-stats', label: '使用统计' },
@ -18,104 +25,6 @@ const adminPages: { key: Page; label: string }[] = [
{ key: 'admin-configs', label: '系统配置' }, { key: 'admin-configs', label: '系统配置' },
] ]
// 统一的导航项组件
function NavItem({
active,
icon,
label,
onClick,
}: {
active: boolean
icon: React.ReactNode
label: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
active
? 'bg-secondary text-white font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span>{icon}</span>
<span>{label}</span>
</button>
)
}
// 子导航项
function SubNavItem({
active,
label,
onClick,
}: {
active: boolean
label: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
active
? 'bg-secondary/10 text-secondary font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
<span>{label}</span>
</button>
)
}
// 分组标题
function GroupLabel({ label }: { label: string }) {
return (
<div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
{label}
</div>
)
}
// 可折叠分组
function NavGroup({
icon,
label,
active,
expanded,
onToggle,
children,
}: {
icon: React.ReactNode
label: string
active: boolean
expanded: boolean
onToggle: () => void
children: React.ReactNode
}) {
return (
<div>
<button
onClick={onToggle}
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
active
? 'bg-secondary text-white font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<div className="flex items-center gap-3">
<span>{icon}</span>
<span>{label}</span>
</div>
<span className={`transition-transform text-xs ${expanded ? 'rotate-90' : ''}`}>▶</span>
</button>
{expanded && <div className="ml-2 space-y-1 mt-1">{children}</div>}
</div>
)
}
export default function Sidebar({ export default function Sidebar({
current, current,
onNavigate, onNavigate,
@ -128,6 +37,7 @@ export default function Sidebar({
onLogout?: () => void onLogout?: () => void
}) { }) {
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({ const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
inquiry: true,
system: true, system: true,
admin: true, admin: true,
}) })
@ -135,6 +45,10 @@ export default function Sidebar({
const toggleGroup = (id: string) => const toggleGroup = (id: string) =>
setExpandedGroups((prev) => ({ ...prev, [id]: !prev[id] })) setExpandedGroups((prev) => ({ ...prev, [id]: !prev[id] }))
const inScene = Object.entries(sceneGroups).find(([, g]) =>
g.pages.includes(current)
)
const isInAdmin = current.startsWith('admin-') const isInAdmin = current.startsWith('admin-')
return ( return (
@ -152,84 +66,186 @@ export default function Sidebar({
</div> </div>
<nav className="flex-1 p-3 space-y-1 overflow-y-auto"> <nav className="flex-1 p-3 space-y-1 overflow-y-auto">
<NavItem <button
active={current === 'overview'}
icon="📊"
label="工作台首页"
onClick={() => onNavigate('overview')} onClick={() => onNavigate('overview')}
/> className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
current === 'overview'
? 'bg-secondary text-white font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span>📊</span>
<span>工作台首页</span>
</button>
<div className="border-t border-border-subtle my-2" /> <div className="border-t border-border-subtle my-2" />
<GroupLabel label="业务场景" />
<NavItem <div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
active={current === 'inquiry'} 业务场景
icon="📋" </div>
label="询价发布助手"
<div>
<button
onClick={() => toggleGroup('inquiry')}
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
inScene?.[0] === 'inquiry'
? 'bg-secondary text-white font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<div className="flex items-center gap-3">
<span>📋</span>
<span>询价发布助手</span>
</div>
<span className={`transition-transform text-xs ${expandedGroups.inquiry ? 'rotate-90' : ''}`}>▶</span>
</button>
{expandedGroups.inquiry && (
<div className="ml-2 space-y-1 mt-1">
<button
onClick={() => onNavigate('inquiry')} onClick={() => onNavigate('inquiry')}
/> className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
current === 'inquiry'
? 'bg-secondary/10 text-secondary font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
<span>线索池</span>
</button>
<button
onClick={() => onNavigate('lead-detail')}
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
current === 'lead-detail'
? 'bg-secondary/10 text-secondary font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
<span>线索详情</span>
</button>
</div>
)}
</div>
{[ {[
{ id: 'image', key: 'image' as Page, label: '商品生图助手', icon: '🖼️' }, { id: 'image', key: 'image' as Page, label: '商品生图助手', icon: '🖼️' },
{ id: 'product', key: 'product' as Page, label: '商品上架助手', icon: '📦' }, { id: 'product', key: 'product' as Page, label: '商品上架助手', icon: '📦' },
{ id: 'comparison', key: 'comparison' as Page, label: '智能比价助手', icon: '⚖️' }, { id: 'comparison', key: 'comparison' as Page, label: '智能比价助手', icon: '⚖️' },
].map((scene) => ( ].map((scene) => (
<NavItem <button
key={scene.id} key={scene.id}
active={current === scene.key}
icon={scene.icon}
label={scene.label}
onClick={() => onNavigate(scene.key)} onClick={() => onNavigate(scene.key)}
/> className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
current === scene.key
? 'bg-secondary text-white font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span>{scene.icon}</span>
<span>{scene.label}</span>
</button>
))} ))}
<div className="border-t border-border-subtle my-2" /> <div className="border-t border-border-subtle my-2" />
<GroupLabel label="系统管理" />
<NavGroup <div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
icon="⚙️" 系统管理
label="系统管理" </div>
active={['report-center', 'rule-center', 'system-monitor'].includes(current)}
expanded={expandedGroups.system} <div>
onToggle={() => toggleGroup('system')} <button
onClick={() => toggleGroup('system')}
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
['report-center', 'rule-center', 'system-monitor', 'integrations'].includes(current)
? 'bg-secondary text-white font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
> >
<SubNavItem <div className="flex items-center gap-3">
active={current === 'report-center'} <span>⚙️</span>
label="报告中心" <span>系统管理</span>
</div>
<span className={`transition-transform text-xs ${expandedGroups.system ? 'rotate-90' : ''}`}>▶</span>
</button>
{expandedGroups.system && (
<div className="ml-2 space-y-1 mt-1">
<button
onClick={() => onNavigate('report-center')} onClick={() => onNavigate('report-center')}
/> className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
<SubNavItem current === 'report-center'
active={current === 'rule-center'} ? 'bg-secondary/10 text-secondary font-semibold'
label="规则中心" : 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
<span>报告中心</span>
</button>
<button
onClick={() => onNavigate('rule-center')} onClick={() => onNavigate('rule-center')}
/> className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
<SubNavItem current === 'rule-center'
active={current === 'system-monitor'} ? 'bg-secondary/10 text-secondary font-semibold'
label="系统监控" : 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
<span>规则中心</span>
</button>
<button
onClick={() => onNavigate('system-monitor')} onClick={() => onNavigate('system-monitor')}
/> className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
</NavGroup> current === 'system-monitor'
? 'bg-secondary/10 text-secondary font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
<span>系统监控</span>
</button>
</div>
)}
</div>
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<> <>
<div className="border-t border-border-subtle my-2" /> <div className="border-t border-border-subtle my-2" />
<GroupLabel label="运营管理" /> <div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
<NavGroup 运营管理
icon="🔧" </div>
label="运营后台" <div>
active={isInAdmin} <button
expanded={expandedGroups.admin} onClick={() => toggleGroup('admin')}
onToggle={() => toggleGroup('admin')} className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
isInAdmin
? 'bg-secondary text-white font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
> >
<div className="flex items-center gap-3">
<span>🔧</span>
<span>运营后台</span>
</div>
<span className={`transition-transform text-xs ${expandedGroups.admin ? 'rotate-90' : ''}`}>▶</span>
</button>
{expandedGroups.admin && (
<div className="ml-2 space-y-1 mt-1">
{adminPages.map((p) => ( {adminPages.map((p) => (
<SubNavItem <button
key={p.key} key={p.key}
active={current === p.key}
label={p.label}
onClick={() => onNavigate(p.key)} onClick={() => onNavigate(p.key)}
/> className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
current === p.key
? 'bg-secondary/10 text-secondary font-semibold'
: 'text-on-surface-variant hover:bg-surface-container-low'
}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-current" />
<span>{p.label}</span>
</button>
))} ))}
</NavGroup> </div>
)}
</div>
</> </>
)} )}
</nav> </nav>

View File

@ -1,34 +0,0 @@
'use client'
interface LightboxProps {
src: string | null
alt?: string
onClose: () => void
}
export default function Lightbox({ src, alt = '预览', onClose }: LightboxProps) {
if (!src) return null
return (
<div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-8 animate-[fadeIn_0.15s_ease-out]"
onClick={onClose}
>
<div className="relative max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
<img
src={src}
alt={alt}
className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
/>
<button
onClick={onClose}
className="absolute -top-3 -right-3 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center hover:bg-slate-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)
}

View File

@ -1,79 +0,0 @@
'use client'
import { useEffect, useCallback } from 'react'
interface ModalProps {
open: boolean
onClose: () => void
title: string
subtitle?: string
children: React.ReactNode
footer?: React.ReactNode
width?: string // default 'w-[520px]'
loading?: boolean // disables close on backdrop click
}
export default function Modal({
open,
onClose,
title,
subtitle,
children,
footer,
width = 'w-[520px]',
loading = false,
}: ModalProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape' && !loading) onClose()
},
[onClose, loading],
)
useEffect(() => {
if (!open) return
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, handleKeyDown])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20 animate-[fadeIn_0.15s_ease-out]"
onClick={() => !loading && onClose()}
>
<div
className={`bg-white rounded-xl shadow-2xl ${width} max-h-[calc(100vh-120px)] flex flex-col transform animate-[slideUp_0.2s_ease-out]`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-5 border-b border-border-subtle flex justify-between items-center flex-shrink-0">
<div>
<h3 className="text-base font-semibold">{title}</h3>
{subtitle && <p className="text-xs text-on-surface-variant mt-1">{subtitle}</p>}
</div>
<button
disabled={loading}
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="p-5 overflow-y-auto flex-1">{children}</div>
{/* Footer */}
{footer && (
<div className="p-4 border-t border-border-subtle flex gap-2 justify-end flex-shrink-0">
{footer}
</div>
)}
</div>
</div>
)
}

View File

@ -1,68 +0,0 @@
'use client'
// 通用骨架屏基础块
function SkeletonBlock({ className = '' }: { className?: string }) {
return (
<div
className={`rounded bg-gradient-to-r from-slate-100 via-slate-200 to-slate-100 bg-[length:200%_100%] animate-[shimmer_1.5s_ease-in-out_infinite] ${className}`}
/>
)
}
// 文本行骨架
export function SkeletonText({ lines = 3, className = '' }: { lines?: number; className?: string }) {
return (
<div className={`space-y-2 ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<SkeletonBlock
key={i}
className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`}
/>
))}
</div>
)
}
// 表格骨架
export function SkeletonTable({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
return (
<div className="bg-white border border-border-subtle rounded-xl overflow-hidden">
{/* 表头 */}
<div className="bg-slate-50 border-b border-border-subtle px-4 py-3 flex gap-4">
{Array.from({ length: cols }).map((_, i) => (
<SkeletonBlock key={`h-${i}`} className="h-4 flex-1" />
))}
</div>
{/* 行 */}
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="px-4 py-3 border-b border-border-subtle flex gap-4">
{Array.from({ length: cols }).map((_, c) => (
<SkeletonBlock key={`${r}-${c}`} className="h-4 flex-1" />
))}
</div>
))}
</div>
)
}
// 卡片骨架
export function SkeletonCard({ className = '' }: { className?: string }) {
return (
<div className={`bg-white border border-border-subtle rounded-xl p-6 space-y-4 ${className}`}>
<SkeletonBlock className="h-4 w-1/3" />
<SkeletonBlock className="h-8 w-1/2" />
<SkeletonBlock className="h-3 w-2/3" />
</div>
)
}
// 指标卡片行骨架(用于 Overview 等页面的顶部统计行)
export function SkeletonMetricRow({ count = 4 }: { count?: number }) {
return (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: count }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
)
}

View File

@ -1,74 +0,0 @@
'use client'
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface ToastItem {
id: number
message: string
type: ToastType
}
interface ToastContextValue {
showToast: (message: string, type?: ToastType) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) {
// fallback: 返回空函数,避免未包裹 Provider 时报错
return { showToast: (msg: string) => console.warn('ToastProvider not mounted:', msg) }
}
return ctx
}
const TYPE_STYLES: Record<ToastType, { bg: string; icon: string }> = {
success: { bg: 'bg-emerald-600', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
error: { bg: 'bg-rose-600', icon: 'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
warning: { bg: 'bg-amber-500', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
info: { bg: 'bg-slate-800', icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const idRef = useRef(0)
const showToast = useCallback((message: string, type: ToastType = 'info') => {
const id = ++idRef.current
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, 2500)
}, [])
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast 容器 */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] flex flex-col-reverse gap-2 pointer-events-none">
{toasts.map((t) => {
const style = TYPE_STYLES[t.type]
return (
<div
key={t.id}
className={`${style.bg} text-white px-5 py-2.5 rounded-lg text-sm shadow-lg pointer-events-auto flex items-center gap-2 animate-[slideUp_0.2s_ease-out] min-w-[200px] max-w-[420px]`}
onClick={() => dismiss(t.id)}
>
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={style.icon} />
</svg>
<span className="truncate">{t.message}</span>
</div>
)
})}
</div>
</ToastContext.Provider>
)
}

View File

@ -10,8 +10,6 @@ const config: Config = {
'border-subtle': '#E2E8F0', 'border-subtle': '#E2E8F0',
'success-green': '#10B981', 'success-green': '#10B981',
'error-red': '#EF4444', 'error-red': '#EF4444',
'info-blue': '#3B82F6',
'warning-amber': '#F59E0B',
background: '#f7f9fb', background: '#f7f9fb',
'surface-white': '#FFFFFF', 'surface-white': '#FFFFFF',
'surface-container': '#eceef0', 'surface-container': '#eceef0',

File diff suppressed because one or more lines are too long

View File

@ -80,7 +80,7 @@ sshpass -p 'Jubianshuzi@2026' ssh ubuntu@172.81.241.109 "
# 4. 验证 # 4. 验证
curl -s -o /dev/null -w 'API: %{http_code}\n' http://172.81.241.109:4000/api/health curl -s -o /dev/null -w 'API: %{http_code}\n' http://172.81.241.109:4000/api/health
curl -s -o /dev/null -w 'Web: %{http_code}\n' https://direv.cloud curl -s -o /dev/null -w 'Web: %{http_code}\n' https://zoupeng.cloud
``` ```
--- ---
@ -105,8 +105,6 @@ curl -s -o /dev/null -w 'Web: %{http_code}\n' https://direv.cloud
| 2026-06-22 | **端到端验证新字段入库**:rfqId=2068990783236042754,orderId=RFQ20260622173337369,MySQL 里 11 个商务字段都正确填充(purchase_quantity=50, inquiry_valid_date=2026-08-31, deliver_date=2026-09-30, shipping_address, contact_name="郑钊", contact_phone=15532027557, inquiry_party="郑州巨变", freight_flag=1, invoice_type=2, procurement_type=1, urls)。**唯一未入库:`payment_method` 字段为 NULL**,原因:devbox 按企业 ID + 名称查 `tb_supplier_pay_type` 表,没找到 "货到付款" 这个字典项就会抛错但不影响 publish 成功(注意日志可能有 `付款方式查询失败: ...` 警告)| — | | 2026-06-22 | **端到端验证新字段入库**:rfqId=2068990783236042754,orderId=RFQ20260622173337369,MySQL 里 11 个商务字段都正确填充(purchase_quantity=50, inquiry_valid_date=2026-08-31, deliver_date=2026-09-30, shipping_address, contact_name="郑钊", contact_phone=15532027557, inquiry_party="郑州巨变", freight_flag=1, invoice_type=2, procurement_type=1, urls)。**唯一未入库:`payment_method` 字段为 NULL**,原因:devbox 按企业 ID + 名称查 `tb_supplier_pay_type` 表,没找到 "货到付款" 这个字典项就会抛错但不影响 publish 成功(注意日志可能有 `付款方式查询失败: ...` 警告)| — |
| 2026-06-23 | **密钥再次重置 + 司库系统重发**:devbox 傍晚又把密钥重置(hash 前缀 `m3hRN → diPRva`,update_date 18:18:22)。用 `reset-secret` 端点再次自助重置拿到新密钥 `fcb53684845648e2a7fc1082f5245180cfa4eea1f5404176`。**用户反馈现象**:选了 `司库系统升级完善项目` 点一键发布,本地显示"发布成功"但 B2B 后台查不到。**根因**:MCP 鉴权失败后代码降级为本地假订单号(X+时间戳),让用户误以为成功。 | scripts/rotate-mcp-key.sh | | 2026-06-23 | **密钥再次重置 + 司库系统重发**:devbox 傍晚又把密钥重置(hash 前缀 `m3hRN → diPRva`,update_date 18:18:22)。用 `reset-secret` 端点再次自助重置拿到新密钥 `fcb53684845648e2a7fc1082f5245180cfa4eea1f5404176`。**用户反馈现象**:选了 `司库系统升级完善项目` 点一键发布,本地显示"发布成功"但 B2B 后台查不到。**根因**:MCP 鉴权失败后代码降级为本地假订单号(X+时间戳),让用户误以为成功。 | scripts/rotate-mcp-key.sh |
| 2026-06-23 | **彻底移除降级本地模式 + MCP 失败弹警告**:(1) 后端 `publishToDirev` 不再写本地假 `inquiryNo=X...`;MCP 鉴权/调用失败时抛 `ServiceUnavailableException` 返回 503 + 详细错误信息;Lead 状态完全不动。(2) 前端 `doPublish` 收集每条失败原因(fetch `res.json().message`),失败时弹新 Modal(红框 + 错误列表 + 「去 MCP 配置」/「我知道了」按钮)。**端到端验证**:把密钥改成 `invalid_test_key_for_qa` → publish 返回 HTTP 503 + JSON 含 `appSecret 校验失败` + 引导文案;Lead 表 publish/inquiryNo/followLogs 全部不变。 | apps/api/src/lead/lead.service.ts, apps/web/src/components/InquiryPage.tsx | | 2026-06-23 | **彻底移除降级本地模式 + MCP 失败弹警告**:(1) 后端 `publishToDirev` 不再写本地假 `inquiryNo=X...`;MCP 鉴权/调用失败时抛 `ServiceUnavailableException` 返回 503 + 详细错误信息;Lead 状态完全不动。(2) 前端 `doPublish` 收集每条失败原因(fetch `res.json().message`),失败时弹新 Modal(红框 + 错误列表 + 「去 MCP 配置」/「我知道了」按钮)。**端到端验证**:把密钥改成 `invalid_test_key_for_qa` → publish 返回 HTTP 503 + JSON 含 `appSecret 校验失败` + 引导文案;Lead 表 publish/inquiryNo/followLogs 全部不变。 | apps/api/src/lead/lead.service.ts, apps/web/src/components/InquiryPage.tsx |
| 2026-06-25 | **生产部署(commit 49f9224)**:本地全量构建后 rsync 到 `/opt/pingtai/`;生产跑 `npx prisma db push + generate`;生产 `next build`(有 lockfile 警告但构建成功);`pm2 restart pingtai-api pingtai-web --update-env`。**手工补齐生产 db 缺列**(prisma db push 路径错):真生产 db 是 `/data/pingtai/dev.db`(不是 `/opt/pingtai/apps/api/prisma/dev.db`),缺 `Lead.regionDetail/quantity/unit/attachments/notes/notesExtractedAt/notesSource` + `Integration.companyUserId` 共 8 列。手工 `ALTER TABLE` 全部补齐,95 条 Lead 数据完整保留。**生产验证**:API + Web online 13m+;lead 95 条、product 75 条、crawler 3 条、inquiry 3 条都正常返回。**新建 Integration 配置**(生产 db 之前为空):在生产创建 MCP 集成 id=751ab09c-e6ab-4c97-bd2d-2c317df32893,appId=app_9dd07cb4e0e24fccbecd。**遗留问题**:生产服务器 172.81.241.109 → devbox 100.111.84.16:3002 网络不通(ping 100% 丢包,curl 5s 超时)。MCP publish_rfq 端到端在生产跑不通(生产 connect 集成返回 `fetch failed`),publish 接口在生产会返回 503 + "未配置 B2B 集成"或"网络不通",这是网络层面的限制不是代码问题。 | apps/api/src/lead/lead.service.ts, docs/本地开发工作流.md |
| 2026-06-25 | **主域名切换:弃用 zoupeng.cloud,改用 direv.cloud**。**根因**:`zoupeng.cloud` DNS 解析到 `222.137.3.123`(旧/无效 IP,ping 通但 443 不通),而 `direv.cloud` DNS 正确解析到 `172.81.241.109`(实际生产服务器 IP)。**改动**:文档第 83 行 `https://zoupeng.cloud` → `https://direv.cloud`,rsync 同步到生产。**长期建议**:去腾讯云 DNSPod 控制台把 `zoupeng.cloud` 的 A 记录改到 `172.81.241.109`;生产 nginx 加 `zoupeng.cloud` 的 server block(同 direv.cloud),否则 `zoupeng.cloud` 永远打不开。AGENTS.md 里"主 `https://zoupeng.cloud`、备 `https://direv.cloud`"是错的,应反过来。 | docs/本地开发工作流.md |
| 2026-06-22 | **devbox F002 升级完成 + 平台助手二次适配**:devbox 已把 publish_rfq schema 升级(W29.7, 补 11 商务字段),Java 后端 `InquiryServiceImpl.publish()` 也补全字段映射。平台助手这边把新参数加进 `publishToDirev` 的 `publishArgs`:deadline(直接传 Lead.deadline YYYY-MM-DD)、deliveryDate(自动 deadline+30 天)、shippingAddress(优先 Lead.regionDetail, fallback region)、contactName/Phone(从 Lead.contact/phone)、inquiryParty("郑州巨变")、freightFlag("含运费")、procurementType("定量采购")、invoiceType("增值税专票")、paymentMethod("货到付款") | apps/api/src/lead/lead.service.ts | | 2026-06-22 | **devbox F002 升级完成 + 平台助手二次适配**:devbox 已把 publish_rfq schema 升级(W29.7, 补 11 商务字段),Java 后端 `InquiryServiceImpl.publish()` 也补全字段映射。平台助手这边把新参数加进 `publishToDirev` 的 `publishArgs`:deadline(直接传 Lead.deadline YYYY-MM-DD)、deliveryDate(自动 deadline+30 天)、shippingAddress(优先 Lead.regionDetail, fallback region)、contactName/Phone(从 Lead.contact/phone)、inquiryParty("郑州巨变")、freightFlag("含运费")、procurementType("定量采购")、invoiceType("增值税专票")、paymentMethod("货到付款") | apps/api/src/lead/lead.service.ts |
| 2026-06-22 | **devbox 升级导致密钥重置**:`app_9dd07cb4e0e24fccbecd` 的 appSecret 被重置(MySQL 里 hash 前缀从 `$2a$10$iR3Rl` 变 `$2a$10$m3hRN`)。用 `POST /sys/openapi/app/reset-secret/{id}` 端点自助重置拿到新密钥 `d02dc00c6fbe4a34a5bf013134146c3f2759148c61a341d2`,本地集成密钥已轮换 | scripts/rotate-mcp-key.sh | | 2026-06-22 | **devbox 升级导致密钥重置**:`app_9dd07cb4e0e24fccbecd` 的 appSecret 被重置(MySQL 里 hash 前缀从 `$2a$10$iR3Rl` 变 `$2a$10$m3hRN`)。用 `POST /sys/openapi/app/reset-secret/{id}` 端点自助重置拿到新密钥 `d02dc00c6fbe4a34a5bf013134146c3f2759148c61a341d2`,本地集成密钥已轮换 | scripts/rotate-mcp-key.sh |
| 2026-06-22 | **端到端验证新字段入库**:rfqId=2068990783236042754,orderId=RFQ20260622173337369,MySQL 里 11 个商务字段都正确填充(purchase_quantity=50, inquiry_valid_date=2026-08-31, deliver_date=2026-09-30, shipping_address, contact_name="郑钊", contact_phone=15532027557, inquiry_party="郑州巨变", freight_flag=1, invoice_type=2, procurement_type=1, urls)。**唯一未入库:`payment_method` 字段为 NULL**,原因:devbox 按企业 ID + 名称查 `tb_supplier_pay_type` 表,没找到 "货到付款" 这个字典项就会抛错但不影响 publish 成功(注意日志可能有 `付款方式查询失败: ...` 警告)| — | | 2026-06-22 | **端到端验证新字段入库**:rfqId=2068990783236042754,orderId=RFQ20260622173337369,MySQL 里 11 个商务字段都正确填充(purchase_quantity=50, inquiry_valid_date=2026-08-31, deliver_date=2026-09-30, shipping_address, contact_name="郑钊", contact_phone=15532027557, inquiry_party="郑州巨变", freight_flag=1, invoice_type=2, procurement_type=1, urls)。**唯一未入库:`payment_method` 字段为 NULL**,原因:devbox 按企业 ID + 名称查 `tb_supplier_pay_type` 表,没找到 "货到付款" 这个字典项就会抛错但不影响 publish 成功(注意日志可能有 `付款方式查询失败: ...` 警告)| — |

217
package-lock.json generated
View File

@ -32,7 +32,6 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdf-parse": "^2.4.5",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@ -1210,190 +1209,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nestjs/cli": { "node_modules/@nestjs/cli": {
"version": "10.4.9", "version": "10.4.9",
"dev": true, "dev": true,
@ -7024,38 +6839,6 @@
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
}, },
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmmirror.com/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"license": "ISC" "license": "ISC"

View File

@ -1,98 +0,0 @@
# 品类清理建议
**生成时间**: 2026-07-01
**当前状态**: 761 条线索 100% 已分配品类,42 个品类(15 个初始 + 27 个扩展/AI 新建)
## ✅ 保留的初始 15 品类(主类目)
| 品类 | 线索数 | 说明 |
|------|--------|------|
| 钢材 | 206 | 最常见品类 |
| 管材 | 79 | |
| 混凝土 | 75 | |
| 电线电缆 | 65 | |
| 仪表 | 57 | |
| 电机 | 35 | |
| 风机 | 33 | |
| 阀门 | 25 | |
| 配电箱 | 19 | |
| 变压器 | 17 | |
| 开关 | 16 | |
| 涂料 | 15 | 含新增的消毒剂/引气剂/化学药剂 |
| 水泵 | 11 | |
| 灯具 | 9 | |
| 防火门 | 7 | |
## 🆕 扩展品类(建议保留,单条 ≥ 5)
| 品类 | 线索数 | 关键词 | 建议 |
|------|--------|--------|------|
| 设备配件 | 11 | 配件/配套设备/螺栓/拉伸机/设备 | ✅ 保留 |
| 工程服务 | 9 | 服务/劳务/可行性研究/运维 | ✅ 保留 |
| 杂品 | 8 | 零星/小五金/棉被/三防布/化学药剂 | ⚠️ 保留但加备注"杂项" |
| 防火门 | 7 | 防火门/防火卷帘/防火窗 | ✅ 主类目已存在 |
| 标识标牌 | 6 | 标识/标牌/广告/安全标准化 | ✅ 保留(采购高频) |
| 施工设备 | 6 | 盾构/钻爆/登高车/平台/备品备件 | ⚠️ 可合并到"设备配件" |
| 油料 | 5 | 柴油/汽油/润滑油 | ✅ 保留 |
| 网络设备 | 4 | 网络/服务器/交换机 | ⚠️ 可合并到"设备配件" |
| 电梯 | 3 | 电梯/扶梯/升降机 | ⚠️ 可合并到"钢材"或"杂品" |
## 🗑️ AI 新建但建议删除/合并的细分类(≤ 2 条)
| 品类 | 线索数 | 关键词 | 建议动作 |
|------|--------|--------|----------|
| 环保设备 | 3 | 空气净化/净化设备 | ⚠️ 可删除,归"杂品" |
| 消防器材 | 3 | 消防/灭火 | ⚠️ 可合并到"防火门"或独立保留 |
| 石材 | 2 | 石材/大理石/花岗岩/块石 | ⚠️ 可合并到"管材"或"杂品" |
| 生活物资 | 2 | 生活物资 | ⚠️ 删除,归"杂品" |
| 服务器 | 2 | 服务器 | ⚠️ 合并到"网络设备" |
| 土工材料 | 2 | 土工布/防水板 | ⚠️ 可保留(独立品类) |
| 劳保用品 | 2 | 劳保 | ⚠️ 合并到"杂品" |
| 集成房屋 | 1 | 集成房屋 | 🗑️ 删除,归"杂品" |
| 钻探设备 | 1 | 钻探 | 🗑️ 删除,归"施工设备"→"设备配件" |
| 绳索 | 1 | 绳索 | 🗑️ 删除,归"杂品" |
| 石英石 | 1 | 石英石 | 🗑️ 删除,归"石材"→"管材" |
| 盾构刀具 | 1 | 盾构刀具 | 🗑️ 删除,归"施工设备"→"设备配件" |
| 洒水车 | 1 | 洒水车 | 🗑️ 删除,归"杂品" |
| 法兰螺栓 | 1 | 法兰/螺栓 | 🗑️ 删除,归"钢材"或"设备配件" |
| 油缸 | 1 | 油缸 | 🗑️ 删除,归"设备配件" |
| 柴油 | 1 | 柴油 | ⚠️ 合并到"油料" |
| 机柜 | 1 | 机柜 | 🗑️ 删除,归"配电箱" |
| 无功补偿装置 | 1 | 无功补偿 | 🗑️ 删除,归"配电箱" |
| 数据服务 | 1 | 数据服务 | 🗑️ 删除,归"工程服务" |
| 搬运设备 | 1 | 搬运 | 🗑️ 删除,归"施工设备"→"设备配件" |
| 挖掘机 | 1 | 挖掘机 | 🗑️ 删除,归"施工设备"→"设备配件" |
| 建筑材料 | 1 | 建筑材料 | 🗑️ 删除,归"钢材"或"杂品" |
| 广告材料 | 1 | 广告 | 🗑️ 删除,归"标识标牌" |
| 工具 | 1 | 工具 | 🗑️ 删除,归"杂品"或"设备配件" |
| 审计服务 | 1 | 审计 | 🗑️ 删除,归"工程服务" |
## 📊 建议的最终品类清单(约 20-22 个)
### 核心主类目(15 个,初始 + 微调)
钢材 / 管材 / 混凝土 / 电线电缆 / 仪表 / 电机 / 风机 / 阀门 / 配电箱 / 变压器 / 开关 / 涂料 / 水泵 / 灯具 / 防火门
### 推荐新增(5-7 个,按需 review)
- **设备配件** ✅(含螺栓/法兰/搬运/挖掘机/钻探设备/机柜/无功补偿装置 等)
- **工程服务** ✅(含服务/劳务/数据/审计 等)
- **杂品** ✅(含小五金/棉被/化学药剂/洒水车/集成房屋/工具 等)
- **标识标牌** ✅(含广告/标牌/安全标准化)
- **油料** ✅(含柴油/汽油/润滑油)
- **电梯** ⚠️ 可合并到"钢材"或独立保留
- **施工设备** ⚠️ 可合并到"设备配件"或独立保留
- **网络设备** ⚠️ 可合并到"设备配件"或独立保留
- **消防器材** ⚠️ 可合并到"防火门"或独立保留
- **土工材料** ⚠️ 可合并到"管材"或独立保留
## 操作建议
1. 进入 AdminPage → 品类管理
2. 删除「AI 自动创建」的 1-2 条细分类(钻探设备、绳索、石英石、盾构刀具、洒水车、法兰螺栓、油缸、机柜、无功补偿装置 等单条品类)
3. 合并「相似品类」(柴油→油料,挖掘机→设备配件,工具→杂品,集成房屋→杂品 等)
4. 保留「高频品类」(钢材、管材、混凝土 等 15 主类目 + 设备配件/工程服务/标识标牌/油料 等扩展)
## 后续维护
- **新增采集场景**:编辑 `ProductCategory.keywords`(AdminPage)
- **新数据采集**:采集器入库时自动走关键词匹配(CrawlerService.batchInfer 已接入)
- **AI 新建品类**:自动标记 `isAIGenerated=true`,人工 review 后可手动改为 false

View File

@ -1,47 +0,0 @@
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
const ASSIGNMENTS = [
{ keyword: '消毒剂', category: '涂料' },
{ keyword: '重水供应', category: '仪表' },
{ keyword: '拦污网', category: '杂品' },
{ keyword: '110kV变电站施工材料', category: '电线电缆' },
{ keyword: '线路金具', category: '电线电缆' },
{ keyword: '弱电材料', category: '电线电缆' },
{ keyword: '弱电智能化', category: '电线电缆' },
{ keyword: '引气剂', category: '涂料' },
{ keyword: '光伏支架', category: '钢材' },
{ keyword: '淋浴隔断', category: '杂品' },
]
async function main() {
const emptyLeads = await prisma.lead.findMany({
where: { OR: [{ category: null }, { category: '' }] },
select: { id: true, title: true },
})
console.log(`剩余空品类线索: ${emptyLeads.length} 条`)
let assigned = 0
for (const lead of emptyLeads) {
for (const a of ASSIGNMENTS) {
if (lead.title.includes(a.keyword)) {
await prisma.lead.update({ where: { id: lead.id }, data: { category: a.category } })
assigned++
console.log(`✓ ${lead.title.slice(0, 60)} → ${a.category}`)
break
}
}
}
const stillEmpty = await prisma.lead.count({
where: { OR: [{ category: null }, { category: '' }] },
})
console.log(`\n已分配: ${assigned}, 剩余空品类: ${stillEmpty}`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@ -1,113 +0,0 @@
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions'
const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7'
const KNOWN_CATEGORIES = [
'防火门', '配电箱', '电线电缆', '阀门', '钢材', '涂料', '混凝土',
'变压器', '水泵', '风机', '管材', '开关', '灯具', '仪表', '电机',
]
const EXPLANATION_PREFIX = /^(让我|用户|我需要|好的|现在|首先|分析|根据|对于|这条|这个|考虑|我们|这里|注意|提示|重要|关键|总结|综上|该|其|它|他们|这些|那些|由于|因为|所以|因此|需要|可能|应该|可以|建议|希望|要求|必须)/
function cleanOne(raw) {
if (!raw) return ''
let s = String(raw).trim()
s = s.replace(/<think>[\s\S]*?<\/think>/g, '').trim()
s = s.replace(/\*\*/g, '').trim()
s = s.replace(/^["「『]+|["」』]+$/g, '').trim()
const lines = s.split('\n').map((l) => l.trim()).filter((l) => l)
for (const line of lines) {
if (EXPLANATION_PREFIX.test(line)) continue
if (line.length > 20) continue
if (line.includes(',') || line.includes('。') || line.includes(':') || line.includes(':')) continue
const cleaned = line.replace(/^[\d]+[\.、::]\s*/, '').trim()
if (!cleaned || cleaned.length > 10) continue
if (KNOWN_CATEGORIES.includes(cleaned)) return cleaned
const m = cleaned.match(/^([\u4e00-\u9fa5]{2,6})$/)
if (m && !EXPLANATION_PREFIX.test(m[1])) return m[1]
}
return ''
}
async function inferSingle(title, categories) {
const apiKey = process.env.MINIMAX_API_KEY
if (!apiKey) return ''
const systemPrompt = `你是建材工业品分类助手。任务:给一条采购公告分配一个品类名。
严格规则:
1. 只输出 2-6 个中文字的品类名,不要任何其他内容
2. 不要输出<think>标签
3. 不要输出"让我""根据"等解释文字
4. 不要输出标点符号
参考品类:${categories.join('、')}`
try {
const r = await fetch(MINIMAX_ENDPOINT, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MINIMAX_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: title },
],
temperature: 0,
max_tokens: 500,
}),
})
if (!r.ok) return ''
const data = await r.json()
const content = data.choices?.[0]?.message?.content || ''
return cleanOne(content)
} catch {
return ''
}
}
async function main() {
const categories = await prisma.productCategory.findMany()
const catNames = categories.map((c) => c.name)
const emptyLeads = await prisma.lead.findMany({
where: { OR: [{ category: null }, { category: '' }] },
select: { id: true, title: true },
})
console.log(`空品类线索: ${emptyLeads.length} 条`)
let okCount = 0
let failCount = 0
for (let i = 0; i < emptyLeads.length; i++) {
const lead = emptyLeads[i]
const catName = await inferSingle(lead.title, catNames)
if (catName) {
await prisma.lead.update({ where: { id: lead.id }, data: { category: catName } })
okCount++
if (!catNames.includes(catName)) {
await prisma.productCategory.upsert({
where: { name: catName },
update: {},
create: { name: catName, keywords: JSON.stringify([lead.title.slice(0, 10)]) },
})
catNames.push(catName)
}
} else {
failCount++
}
if ((i + 1) % 20 === 0 || i === emptyLeads.length - 1) {
console.log(`进度 ${i + 1}/${emptyLeads.length} 成功 ${okCount} 失败 ${failCount}`)
}
}
const remaining = await prisma.lead.count({
where: { OR: [{ category: null }, { category: '' }] },
})
console.log(`完成: 成功 ${okCount}, 失败 ${failCount}, 剩余空品类: ${remaining}`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@ -1,79 +0,0 @@
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
const KNOWN_CATEGORIES = new Set([
'防火门', '配电箱', '电线电缆', '阀门', '钢材', '涂料', '混凝土',
'变压器', '水泵', '风机', '管材', '开关', '灯具', '仪表', '电机',
])
const EXPLANATION_PREFIX = /^(让我|用户|我需要|好的|现在|首先|分析|根据|对于|这条|这个|考虑|我们|这里|注意|提示|重要|关键|总结|综上|该|其|它|他们|这些|那些|由于|因为|所以|因此|需要|可能|应该|可以|建议|希望|要求|必须)/
function cleanCategory(raw) {
if (!raw) return ''
let s = String(raw).trim()
s = s.replace(/<think>[\s\S]*?<\/think>/g, '').trim()
s = s.replace(/\*\*/g, '').trim()
s = s.replace(/^["「『]+|["」』]+$/g, '').trim()
const lines = s.split('\n').map((l) => l.trim()).filter((l) => l)
for (const line of lines) {
if (EXPLANATION_PREFIX.test(line)) continue
if (line.length > 20) continue
if (line.includes(',') || line.includes('。') || line.includes(':') || line.includes(':')) continue
if (line.includes(' - ') || line.includes(' – ') || line.includes('--')) {
const parts = line.split(/\s*[-–]\s*/).map((p) => p.trim()).filter((p) => p)
for (const p of parts) {
if (KNOWN_CATEGORIES.has(p)) return p
const m = p.match(/^([\u4e00-\u9fa5]{2,6})$/)
if (m && !EXPLANATION_PREFIX.test(p)) return m[1]
}
continue
}
const cleaned = line.replace(/^[\d]+[\.、::]\s*/, '').trim()
if (!cleaned || cleaned.length > 10) continue
if (KNOWN_CATEGORIES.has(cleaned)) return cleaned
const m = cleaned.match(/^([\u4e00-\u9fa5]{2,6})$/)
if (m && !EXPLANATION_PREFIX.test(m[1])) return m[1]
}
return ''
}
async function main() {
const leads = await prisma.lead.findMany({
where: { NOT: { category: null } },
select: { id: true, category: true },
})
console.log(`共 ${leads.length} 条有品类的线索`)
let fixed = 0
let cleared = 0
for (const lead of leads) {
const cleaned = cleanCategory(lead.category)
if (cleaned !== lead.category) {
await prisma.lead.update({ where: { id: lead.id }, data: { category: cleaned } })
if (cleaned === '') cleared++
else fixed++
}
}
console.log(`修正: ${fixed}, 清空: ${cleared}`)
const remaining = await prisma.lead.count({ where: { OR: [{ category: null }, { category: '' }] } })
console.log(`剩余空品类: ${remaining}`)
const dist = await prisma.lead.groupBy({
by: ['category'],
where: { NOT: { category: '' } },
_count: true,
orderBy: { _count: { category: 'desc' } },
take: 30,
})
console.log('品类分布:')
dist.forEach((d) => console.log(` ${d.category}: ${d._count}`))
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@ -1,36 +0,0 @@
-- 清理垃圾关键词品类脚本
-- 使用:ssh到生产机后执行:sqlite3 /data/pingtai/dev.db < cleanup_garbage_categories.sql
-- 第一步:查看将要删除的品类及其关联线索数
.print '=== 将要清理的垃圾品类 ==='
SELECT pc.name, pc.keywords, COUNT(l.id) AS lead_count
FROM ProductCategory pc
LEFT JOIN Lead l ON l.category = pc.name
WHERE (keywords LIKE '%中国电建%' OR keywords LIKE '%新疆%')
AND length(keywords) < 100
GROUP BY pc.id
ORDER BY lead_count DESC;
-- 第二步:清除被误分类线索的 category 字段
.print ''
.print '=== 清除线索中的错误品类 ==='
UPDATE Lead SET category = ''
WHERE category IN (
SELECT name FROM ProductCategory
WHERE (keywords LIKE '%中国电建%' OR keywords LIKE '%新疆%')
AND length(keywords) < 100
);
SELECT changes() AS '已清除线索数';
-- 第三步:删除垃圾品类记录
.print ''
.print '=== 删除垃圾品类 ==='
DELETE FROM ProductCategory
WHERE (keywords LIKE '%中国电建%' OR keywords LIKE '%新疆%')
AND length(keywords) < 100;
SELECT changes() AS '已删除品类数';
-- 第四步:验证结果
.print ''
.print '=== 验证:剩余品类 ==='
SELECT name, keywords, isAIGenerated FROM ProductCategory ORDER BY name;

View File

@ -1,63 +0,0 @@
#!/bin/bash
set -e
# 一键部署:编译产物 + 源码同步到生产服务器 + PM2 重启
# 用法: bash scripts/deploy.sh
SSH_HOST="ubuntu@172.81.241.109"
DEPLOY_DIR="/opt/pingtai"
SSHPASS_VAR="${SSHPASS:-Jubianshuzi@2026}"
SSH_CMD="sshpass -p $SSHPASS_VAR ssh -o StrictHostKeyChecking=no"
RSYNC_CMD="sshpass -p $SSHPASS_VAR rsync -avz --delete"
echo "=========================================="
echo " 部署开始: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="
# 1. 构建
echo ""
echo "[1/5] 构建 API..."
npm run build -w apps/api 2>&1 | tail -3
echo " ✅ API 构建完成"
echo "[2/5] 构建 Web..."
npm run build -w apps/web 2>&1 | tail -3
echo " ✅ Web 构建完成"
# 2. 同步编译产物
echo ""
echo "[3/5] 同步编译产物..."
$RSYNC_CMD apps/api/dist/ $SSH_HOST:$DEPLOY_DIR/apps/api/dist/ 2>&1 | tail -1
echo " ✅ API dist 同步完成"
$RSYNC_CMD apps/web/.next/ $SSH_HOST:$DEPLOY_DIR/apps/web/.next/ 2>&1 | tail -1
echo " ✅ Web .next 同步完成"
# 3. 同步源码(排除构建产物和依赖)
echo ""
echo "[4/5] 同步源码..."
$RSYNC_CMD \
--exclude='node_modules' \
--exclude='dist' \
--exclude='.next' \
--exclude='*.db' \
--exclude='*.db-journal' \
--exclude='.env' \
--exclude='.env.*' \
--exclude='.git' \
--exclude='.dev-api.pid' \
--exclude='.dev-web.pid' \
--exclude='public/uploads' \
/Users/zou/Documents/02-平台开发/平台助手/ \
$SSH_HOST:$DEPLOY_DIR/ 2>&1 | tail -1
echo " ✅ 源码同步完成"
# 4. 重启 PM2
echo ""
echo "[5/5] 重启 PM2..."
$SSH_CMD $SSH_HOST "source ~/.nvm/nvm.sh 2>/dev/null; pm2 restart pingtai-api pingtai-web --update-env" 2>&1 | tail -3
echo " ✅ PM2 重启完成"
echo ""
echo "=========================================="
echo " 部署完成: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="

View File

@ -1,86 +0,0 @@
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
// 基础 15 品类 + 扩展关键词(覆盖剩余 68 条线索的常见标题模式)
const EXTENDED_CATEGORIES = {
'钢材': ['钢', '钢管', '钢板', '钢筋', '型材', '铜覆', '热镀锌', '塔筒', '金属', '圆线', '钢绞线', '角钢'],
'混凝土': ['混凝土', '商混', '商品砼', '水泥', '砂浆', '砼', '灌浆料'],
'阀门': ['阀门', '闸阀', '球阀', '蝶阀', '截止阀', '止回阀', '调节阀', '控制阀', '节流装置', '旁路'],
'管材': ['管材', '管道', 'PVC', 'PE管', '镀锌管', '无缝管', '螺旋管', '给水管', '铸铁管', '膨胀节', '地砖', '面砖'],
'仪表': ['仪表', '仪器', '传感器', '变送器', '流量计', '压力表', '温度计', '标准孔板', '文丘里', '监测', '监测站', '监测设备'],
'电机': ['电机', '电动机', '减速机', '减速器', '变频', '电气系统'],
'水泵': ['水泵', '离心泵', '潜污泵', '消防泵', '排污泵', '循环泵'],
'变压器': ['变压器', '箱变', '干式变', '油浸变', '电力变压器', '换流站'],
'电线电缆': ['电缆', '电线', '导线', '电力线', '控制电缆', '光缆', '架空线'],
'配电箱': ['配电箱', '配电柜', '配电盘'],
'开关': ['开关', '断路器', '接触器', '继电器', '熔断器', '隔离开关'],
'风机': ['风机', '通风机', '排风机', '轴流风机', '离心风机'],
'灯具': ['灯具', '照明灯', '路灯', '防爆灯', 'LED灯', '太阳能灯'],
'涂料': ['涂料', '油漆', '防腐漆', '防火涂料', '乳胶漆', '防水涂料', '化学药剂', '聚氯化铝'],
'防火门': ['防火门', '防火卷帘', '防火窗', '钢质防火', '木质防火'],
'标识标牌': ['标识', '标牌', '广告', '安全标准化'],
'电梯': ['电梯', '扶梯', '升降机'],
'油料': ['柴油', '汽油', '润滑油', '油料'],
'环保设备': ['空气净化', '净化设备', '除尘', '废气'],
'网络设备': ['网络', '服务器', '计算机', '交换机', 'SVDU', '工作站'],
'消防器材': ['消防', '灭火', '阻燃', '防火材料'],
'施工设备': ['盾构', '钻爆', '登高车', '平台', '备品备件', '台车'],
'石材': ['石材', '大理石', '花岗岩', '块石', '戈壁料'],
'土工材料': ['土工布', '两布一膜', '土工膜', '防水板', '隔墙板'],
'杂品': ['零星', '小五金', '棉被', '三防布', '安全培训', '化学药剂', '介质'],
'工程服务': ['服务', '劳务', '可行性研究', '地质', '技术测评', '运维', '管理', '设计', '评估'],
'设备配件': ['配件', '配套设备', '螺栓', '拉伸机', '设备', '汽机', '氢气储存', '压力容器', '生产线'],
}
async function main() {
const emptyLeads = await prisma.lead.findMany({
where: { OR: [{ category: null }, { category: '' }] },
select: { id: true, title: true },
})
console.log(`空品类线索: ${emptyLeads.length} 条`)
let filled = 0
const remaining = []
for (const lead of emptyLeads) {
let matched = null
for (const [cat, keywords] of Object.entries(EXTENDED_CATEGORIES)) {
if (keywords.some((kw) => lead.title.includes(kw))) {
matched = cat
break
}
}
if (matched) {
await prisma.lead.update({ where: { id: lead.id }, data: { category: matched } })
filled++
} else {
remaining.push(lead)
}
}
console.log(`扩展关键词匹配: ${filled} 条`)
console.log(`仍需人工: ${remaining.length} 条`)
if (remaining.length > 0) {
console.log('\n--- 需要人工判断的标题 ---')
remaining.forEach((l, i) => console.log(`${i + 1}. ${l.title}`))
}
// 新增扩展品类到 ProductCategory 表
const newCats = Object.keys(EXTENDED_CATEGORIES).filter(
(c) => !['防火门', '配电箱', '电线电缆', '阀门', '钢材', '涂料', '混凝土', '变压器', '水泵', '风机', '管材', '开关', '灯具', '仪表', '电机'].includes(c),
)
for (const catName of newCats) {
await prisma.productCategory.upsert({
where: { name: catName },
update: { keywords: JSON.stringify(EXTENDED_CATEGORIES[catName]) },
create: { name: catName, keywords: JSON.stringify(EXTENDED_CATEGORIES[catName]) },
})
}
console.log(`\n已新增/更新 ${newCats.length} 个扩展品类到 ProductCategory`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@ -1,132 +0,0 @@
#!/usr/bin/env python3
"""
从招标公告 PDF 中提取地址信息。
用法: python3 extract_address_from_pdf.py <pdf文件路径> [公告标题]
输出: JSON {"region": "省/市", "regionDetail": "详细地址"}
"""
import fitz
import re
import json
import sys
# 全国省份/直辖市/自治区名称(含简称、别称)
PROVINCES = [
'北京', '天津', '上海', '重庆',
'河北', '山西', '辽宁', '吉林', '黑龙江',
'江苏', '浙江', '安徽', '福建', '江西', '山东',
'河南', '湖北', '湖南', '广东', '海南',
'四川', '贵州', '云南', '陕西', '甘肃', '青海',
'台湾', '内蒙古', '广西', '西藏', '宁夏', '新疆',
]
TWO_CHAR_PROVS = [p for p in PROVINCES if len(p) == 2]
SPECIAL_PROVS = ['内蒙古', '广西', '新疆', '西藏', '宁夏']
def extract_province_best(text):
"""从任意文本中提取最可能的省份(先关键词匹配,避免正则在多字省份上截断)"""
if not text:
return ''
text_clean = text.replace(' ', '')
# 1. 特殊多字省份(优先,避免"新疆维吾尔自治区"被截)
for prov in SPECIAL_PROVS:
if prov in text_clean:
return prov
# 2. 直辖市
for prov in ['北京', '天津', '上海', '重庆']:
if prov in text_clean:
return prov
# 3. "XX省"(如"陕西省"、"广东省")
m = re.search(r'([\u4e00-\u9fff]{2}省)', text)
if m:
return m.group(1)[:2]
# 4. 两字省份关键词
for prov in TWO_CHAR_PROVS:
if prov in text_clean:
return prov
return ''
def extract_city_from_text(text):
"""从文本中提取城市名(XX市)"""
if not text:
return ''
m = re.search(r'([\u4e00-\u9fff]{2,4}(?:市))', text)
if m:
city = m.group(1)
if city not in ['北京市', '天津市', '上海市', '重庆市']:
return city
return ''
def extract_address(pdf_path: str, title: str = '') -> dict:
pdf_text = ''
try:
doc = fitz.open(pdf_path)
for page in doc:
pdf_text += page.get_text()
doc.close()
except Exception:
pdf_text = ''
result = {'region': '', 'regionDetail': ''}
# ── 1. 从PDF正文中提取详细地址 ──
addr_patterns = [
r'建设地点[::]\s*(.+?)(?:\n|$)',
r'项目地点[::]\s*(.+?)(?:\n|$)',
r'交货地点[::]\s*(.+?)(?:\n|$)',
r'施工地点[::]\s*(.+?)(?:\n|$)',
r'项目地址[::]\s*(.+?)(?:\n|$)',
r'供货地点[::]\s*(.+?)(?:\n|$)',
r'到货地点[::]\s*(.+?)(?:\n|$)',
r'送货地点[::]\s*(.+?)(?:\n|$)',
r'工程地点[::]\s*(.+?)(?:\n|$)',
r'实施地点[::]\s*(.+?)(?:\n|$)',
r'服务地点[::]\s*(.+?)(?:\n|$)',
r'收货地址[::]\s*(.+?)(?:\n|$)',
r'所在地[::]\s*(.+?)(?:\n|$)',
]
addr = ''
for p in addr_patterns:
m = re.search(p, pdf_text)
if m:
addr = m.group(1).strip()
if len(addr) >= 4:
break
addr = ''
if addr:
result['regionDetail'] = addr
prov = extract_province_best(addr)
if prov:
result['region'] = prov
else:
city = extract_city_from_text(addr)
if city:
result['region'] = city
# ── 2. 正文没提取到region,从整个PDF文本中搜索 ──
if not result['region'] and pdf_text:
prov = extract_province_best(pdf_text)
if prov:
result['region'] = prov
# ── 3. 还没提取到,从标题中搜索 ──
if not result['region'] and title:
prov = extract_province_best(title)
if prov:
result['region'] = prov
return result
if __name__ == '__main__':
if len(sys.argv) < 2:
print(json.dumps({'region': '', 'regionDetail': ''}))
sys.exit(0)
pdf_path = sys.argv[1]
title = sys.argv[2] if len(sys.argv) > 2 else ''
result = extract_address(pdf_path, title)
print(json.dumps(result, ensure_ascii=False))

View File

@ -1,33 +0,0 @@
-- 修复乱码标题:将 Lead 表中含 U+FFFD 的标题替换为从 PowerChina API 获取的干净版本
-- 先查看当前乱码记录
SELECT '当前乱码记录:' as info;
SELECT id, noticeId, substr(title, 1, 70) as title_trunc FROM Lead WHERE title LIKE '%' || char(65533) || '%';
-- 批量更新
UPDATE Lead SET title = '中国电建重庆工程有限公司兰州新区大数据产业园(二期)项目110kV变配电工程电缆安装施工项目工器具采购项目公开谈判采购公告'
WHERE id = (SELECT id FROM Lead WHERE title LIKE '%兰州新区大数据产业园%' AND title LIKE '%' || char(65533) || '%' LIMIT 1);
UPDATE Lead SET title = '中国电建山东电建一公司海阳核电核能供热银粉漆、醇酸磁漆等采购项目公开竞价采购公告'
WHERE id = (SELECT id FROM Lead WHERE title LIKE '%海阳核电核能供热%' AND title LIKE '%' || char(65533) || '%' LIMIT 1);
UPDATE Lead SET title = '中国电建上海电建公司福建电建上海新能源申能光明长江养殖场 55.8兆瓦渔光互补光伏发电项目EPC总承包箱变采购项目标前公示'
WHERE id = (SELECT id FROM Lead WHERE title LIKE '%上海新能源申能%' AND title LIKE '%' || char(65533) || '%' LIMIT 1);
UPDATE Lead SET title = '中国电建山东电建一公司北方公司黑龙江哈尔滨哈一热项目钢骨架复合管采购项目公开竞价采购公告'
WHERE id = (SELECT id FROM Lead WHERE title LIKE '%黑龙江哈尔滨%' AND title LIKE '%' || char(65533) || '%' LIMIT 1);
UPDATE Lead SET title = '中国电建水电七局三峡枢纽新通道山体排水洞及施工支洞工程柴油采购项目公开询比采购公告'
WHERE id = (SELECT id FROM Lead WHERE title LIKE '%柴油%' AND title LIKE '%' || char(65533) || '%' LIMIT 1);
UPDATE Lead SET title = '中国电建山东电建一公司西北公司新疆喀什煤电项目安全省力型盖板采购项目公开竞价采购公告'
WHERE id = (SELECT id FROM Lead WHERE title LIKE '%新疆喀什%' AND title LIKE '%' || char(65533) || '%' LIMIT 1);
UPDATE Lead SET title = '中国电建重庆工程公司重庆公司重庆电厂2×660MW机组增设启动锅炉改造工程支吊架采购项目公开谈判采购公告'
WHERE id = (SELECT id FROM Lead WHERE title LIKE '%重庆电厂%' AND title LIKE '%' || char(65533) || '%' LIMIT 1);
-- 验证修复结果
SELECT '修复后:' as info;
SELECT id, noticeId, substr(title, 1, 70) as title_trunc FROM Lead WHERE title LIKE '%' || char(65533) || '%';
SELECT '剩余乱码记录数:' as info;
SELECT COUNT(*) FROM Lead WHERE title LIKE '%' || char(65533) || '%';

View File

@ -1,153 +0,0 @@
/**
* 重新分类脚本:对 category 为空的线索重新执行分类
* 用法:node scripts/reclassify_leads.js
*
* 需要在项目根目录运行(使用项目内的 Prisma)
*/
const { PrismaClient } = require('@prisma/client')
// 直接复用 inference 逻辑的内联版本
const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions'
const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7'
const API_KEY = process.env.MINIMAX_API_KEY
function isGarbageKeyword(kw) {
if (/(?:公司|有限|集团|工程局|项目部)$/.test(kw)) return true
if (/^(?:中国电建|新疆)/.test(kw)) return true
const genericTerms = ['公开竞价', '公开招标', '竞争性谈判', '询价采购', '邀请招标']
if (genericTerms.includes(kw)) return true
return false
}
function isValidCategoryName(raw) {
if (!raw) return false
const s = raw.trim()
if (s.length === 0 || s.length > 6) return false
if (/[*\->→]/.test(s)) return false
if (/[,。:;!?、]/.test(s)) return false
if (/[A-Za-z]{2,}/.test(s)) return false
if (!/^[\u4e00-\u9fa5]+$/.test(s)) return false
if (/^(让我|用户|我需要|好的|现在|首先|分析|根据|对于|这条|这个|考虑|我们|这里|注意|提示|重要|关键|总结|综上|该|其|它|他们|这些|那些|由于|因为|所以|因此|需要|可能|应该|可以|建议|希望|要求|必须)/.test(s)) return false
if (/采购|项目|公司|工程|设备|材料|公告|招标|标段|框架|年度/.test(s)) return false
return true
}
function extractCategoryFromLine(line) {
// 剥离数字前缀
let s = line.replace(/^[\d]+[\.、::]\s*/, '').trim()
if (!s) return ''
// 取 → 后面的内容(最可能是品类名)
const arrowIdx = s.lastIndexOf('→')
if (arrowIdx >= 0) {
let afterArrow = s.slice(arrowIdx + 1).trim()
// 提取连续中文字
const m = afterArrow.match(/([\u4e00-\u9fa5]{2,6})/)
if (m && isValidCategoryName(m[1])) return m[1]
}
// 从整句中提取可能的品类名
const parts = s.split(/\s*(?:[-–—]|→)\s*/).map(p => p.trim()).filter(p => p)
// 从最后一部分往前找
for (let i = parts.length - 1; i >= 0; i--) {
const m = parts[i].match(/([\u4e00-\u9fa5]{2,6})/g)
if (m) {
for (let j = m.length - 1; j >= 0; j--) {
if (isValidCategoryName(m[j])) return m[j]
}
}
}
// 从整句提取
const m = s.match(/([\u4e00-\u9fa5]{2,6})/g)
if (m) {
for (let j = m.length - 1; j >= 0; j--) {
if (isValidCategoryName(m[j])) return m[j]
}
}
return ''
}
// 简版: 只对 batch 结果做后处理
function parseBatchResult(content) {
const results = []
// 去掉 think 块
let clean = content.replace(/<think>[\s\S]*?<\/think>/g, '').trim()
const lines = clean.split('\n').map(l => l.trim()).filter(l => l)
for (const line of lines) {
results.push(extractCategoryFromLine(line))
}
return results
}
async function main() {
const prisma = new PrismaClient()
try {
// 1. 找出所有空分类的线索
const leads = await prisma.lead.findMany({
where: { OR: [{ category: null }, { category: '' }] },
select: { id: true, title: true, summary: true },
})
console.log(`找到 ${leads.length} 条待分类线索`)
if (leads.length === 0) {
console.log('无需分类')
return
}
// 2. 获取已有品类
const categories = await prisma.productCategory.findMany()
const catNames = categories.map(c => c.name).join('、')
// 3. 依次尝试关键词匹配,未命中则调 AI
const needsAI = []
const result = new Map()
for (const lead of leads) {
// 关键词匹配(跳过垃圾关键词)
let matched = false
for (const cat of categories) {
const kws = JSON.parse(cat.keywords)
for (const kw of kws) {
if (isGarbageKeyword(kw)) continue
if (lead.title.includes(kw)) {
result.set(lead.id, cat.name)
matched = true
break
}
}
if (matched) break
}
if (!matched) {
needsAI.push(lead)
}
}
console.log(`关键词命中: ${result.size} 条, 需 AI 推断: ${needsAI.length} 条`)
// 4. 未命中的全部归为"其他"
console.log(`关键词未命中的 ${needsAI.length} 条统一归为「其他」品类`)
for (const lead of needsAI) {
result.set(lead.id, '其他')
}
// 5. 更新数据库
let updated = 0
for (const [id, category] of result) {
if (category) {
await prisma.lead.update({
where: { id },
data: { category },
})
updated++
}
}
console.log(`\n完成! 更新了 ${updated}/${leads.length} 条线索的分类`)
console.log(`未分类: ${leads.length - updated} 条(保持为空)`)
} finally {
await prisma.$disconnect()
}
}
main().catch(console.error)

View File

@ -1,107 +0,0 @@
#!/usr/bin/env node
/**
* 清理状态文件中的乱码标题记录 + 删除 DB 中乱码线索,然后重新抓取对应日期
* 用于修复 fetch-v1.2.js chunk 边界截断导致的标题乱码问题
*
* 用法: node scripts/refetch_corrupted.js # 清理并重新抓取
* node scripts/refetch_corrupted.js --dry-run # 仅预览不做操作
*/
const { execSync } = require('child_process')
const fs = require('fs')
const path = require('path')
const BASE_DIR = '/data/pingtai/招标公告'
const SCRIPT = '/opt/pingtai/scripts/tender-fetcher/fetch-v1.2.js'
const DRY_RUN = process.argv.includes('--dry-run')
/** 收集所有乱码条目 */
function findCorrupted() {
const results = []
const dirs = fs.readdirSync(BASE_DIR).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
for (const dir of dirs) {
const statePath = path.join(BASE_DIR, dir, 'downloaded.json')
if (!fs.existsSync(statePath)) continue
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'))
for (const [key, v] of Object.entries(state.downloaded || {})) {
if (v.title && v.title.includes('\uFFFD')) {
results.push({ date: dir, key, statePath, info: v })
}
}
}
return results
}
/** 从状态文件中移除指定 key */
function removeFromState(statePath, key) {
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'))
if (state.downloaded?.[key]) {
delete state.downloaded[key]
fs.writeFileSync(statePath, JSON.stringify(state, null, 2))
return true
}
return false
}
async function main() {
const corrupted = findCorrupted()
const affectedDates = [...new Set(corrupted.map(c => c.date))].sort()
const affectedIds = corrupted.map(c => c.info.id).filter(Boolean)
console.log(`找到 ${corrupted.length} 条乱码记录,涉及 ${affectedDates.length} 天`)
console.log(`受影响日期: ${affectedDates.join(', ')}`)
console.log(`受影响 ID: ${affectedIds.join(', ')}`)
if (corrupted.length === 0) {
console.log('无需清理')
return
}
// 1. 清理状态文件
console.log('\n=== 清理状态文件 ===')
let cleaned = 0
for (const c of corrupted) {
if (DRY_RUN) {
console.log(` [DRY] 将从 ${c.date}/downloaded.json 移除 key: ${c.key}`)
cleaned++
continue
}
if (removeFromState(c.statePath, c.key)) {
console.log(` ✅ ${c.date}: 已移除 ${c.key}`)
cleaned++
}
}
console.log(`已清理 ${cleaned}/${corrupted.length} 条状态记录`)
// 2. 重新抓取每个受影响日期
console.log('\n=== 重新抓取 ===')
for (const date of affectedDates) {
if (DRY_RUN) {
console.log(` [DRY] 将执行: START_DATE=${date} node ${SCRIPT}`)
continue
}
try {
console.log(` ⏳ 抓取 ${date} 的数据...`)
const output = execSync(`START_DATE=${date} node ${SCRIPT}`, {
cwd: path.dirname(SCRIPT),
timeout: 120000,
encoding: 'utf8',
})
// 提取最后几行输出
const lines = output.trim().split('\n').slice(-5).join('\n')
console.log(` ✅ ${date}: 完成\n${lines}`)
} catch (e) {
console.error(` ❌ ${date}: ${e.message}`)
}
// 间隔等待,避免请求过频
await new Promise(r => setTimeout(r, 2000))
}
console.log('\n=== 完成 ===')
if (!DRY_RUN) {
console.log('提示:执行后端采集器以更新 DB(手动触发"中电建"采集器运行)')
console.log('或等待下次定时调度自动更新')
}
}
main().catch(console.error)

View File

@ -1,69 +0,0 @@
/**
* 恢复 ProductCategory 中被误删的 27 个品类 (15 核心 + 12 扩展)。
*
* 使用 expand_keywords.js 中验证过的 EXACT 数据, ensure 完全幂等 (upsert)。
* 历史背景: clean_productcategory.js 的 DIRTY_NAMES 黑名单误把 钢材/管材 等 15 个核心
* 品类当作噪声删除了, 这里恢复它们。
*/
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
const CATEGORIES = {
'钢材': ['钢', '钢管', '钢板', '钢筋', '型材', '铜覆', '热镀锌', '塔筒', '金属', '圆线', '钢绞线', '角钢'],
'混凝土': ['混凝土', '商混', '商品砼', '水泥', '砂浆', '砼', '灌浆料'],
'阀门': ['阀门', '闸阀', '球阀', '蝶阀', '截止阀', '止回阀', '调节阀', '控制阀', '节流装置', '旁路'],
'管材': ['管材', '管道', 'PVC', 'PE管', '镀锌管', '无缝管', '螺旋管', '给水管', '铸铁管', '膨胀节', '地砖', '面砖'],
'仪表': ['仪表', '仪器', '传感器', '变送器', '流量计', '压力表', '温度计', '标准孔板', '文丘里', '监测', '监测站', '监测设备'],
'电机': ['电机', '电动机', '减速机', '减速器', '变频', '电气系统'],
'水泵': ['水泵', '离心泵', '潜污泵', '消防泵', '排污泵', '循环泵'],
'变压器': ['变压器', '箱变', '干式变', '油浸变', '电力变压器', '换流站'],
'电线电缆': ['电缆', '电线', '导线', '电力线', '控制电缆', '光缆', '架空线'],
'配电箱': ['配电箱', '配电柜', '配电盘'],
'开关': ['开关', '断路器', '接触器', '继电器', '熔断器', '隔离开关'],
'风机': ['风机', '通风机', '排风机', '轴流风机', '离心风机'],
'灯具': ['灯具', '照明灯', '路灯', '防爆灯', 'LED灯', '太阳能灯'],
'涂料': ['涂料', '油漆', '防腐漆', '防火涂料', '乳胶漆', '防水涂料', '化学药剂', '聚氯化铝'],
'防火门': ['防火门', '防火卷帘', '防火窗', '钢质防火', '木质防火'],
'标识标牌': ['标识', '标牌', '广告', '安全标准化'],
'电梯': ['电梯', '扶梯', '升降机'],
'油料': ['柴油', '汽油', '润滑油', '油料'],
'环保设备': ['空气净化', '净化设备', '除尘', '废气'],
'网络设备': ['网络', '服务器', '计算机', '交换机', 'SVDU', '工作站'],
'消防器材': ['消防', '灭火', '阻燃', '防火材料'],
'施工设备': ['盾构', '钻爆', '登高车', '平台', '备品备件', '台车'],
'石材': ['石材', '大理石', '花岗岩', '块石', '戈壁料'],
'土工材料': ['土工布', '两布一膜', '土工膜', '防水板', '隔墙板'],
'杂品': ['零星', '小五金', '棉被', '三防布', '安全培训', '化学药剂', '介质'],
'工程服务': ['服务', '劳务', '可行性研究', '地质', '技术测评', '运维', '管理', '设计', '评估'],
'设备配件': ['配件', '配套设备', '螺栓', '拉伸机', '设备', '汽机', '氢气储存', '压力容器', '生产线'],
}
async function main() {
const before = await prisma.productCategory.count()
console.log(`ProductCategory 当前: ${before}`)
let created = 0
let updated = 0
for (const [name, keywords] of Object.entries(CATEGORIES)) {
const kw = JSON.stringify(keywords)
const existing = await prisma.productCategory.findUnique({ where: { name } })
if (existing) {
await prisma.productCategory.update({
where: { name },
data: { keywords: kw },
})
updated++
} else {
await prisma.productCategory.create({
data: { name, keywords: kw, isAIGenerated: false },
})
created++
}
}
const after = await prisma.productCategory.count()
console.log(`恢复品类: 新增 ${created}, 更新 ${updated}`)
console.log(`ProductCategory 总数: ${before} → ${after}`)
}
main().catch((e) => { console.error(e); process.exit(1) }).finally(() => prisma.$disconnect())

View File

@ -1,80 +0,0 @@
// Smoke test: verify sanitizeOne() rejects all known-bad inputs
// Run with: node /tmp/sanitize_smoke.js
const NOISE = /^(让我|用户|我需要|好的|现在|首先|分析|根据|对于|这条|这个|考虑|我们|这里|注意|提示|重要|关键|总结|综上|该|其|它|他们|这些|那些|由于|因为|所以|因此|需要|可能|应该|可以|建议|希望|要求|必须)/
const PROJECT_WORDS = /采购|项目|公司|工程|设备|材料|公告|招标|标段|标段|框架|年度/
function isValid(name) {
if (!name) return false
const s = name.trim()
if (s.length === 0 || s.length > 6) return false
if (/[*\->→]/.test(s)) return false
if (/[,。:;!?、]/.test(s)) return false
if (/[A-Za-z]{2,}/.test(s)) return false
if (NOISE.test(s)) return false
if (PROJECT_WORDS.test(s)) return false
if (/^(中国|钢材|项目|采购|这些|这个|公司|工程|设备)$/.test(s)) return false
if (/^[一-龥]+$/.test(s) === false) return false
return true
}
function sanitize(raw) {
if (!raw) return ''
let s = String(raw).replace(/<\/?think>/g, '').replace(/\*\*/g, '').trim()
const lines = s.split('\n').map((l) => l.trim()).filter((l) => l)
for (const line of lines) {
if (NOISE.test(line)) continue
if (line.length > 20) continue
if (/[,。:;!?、]/.test(line)) continue
const cleaned = line.replace(/^[\d]+[\.、::]\s*/, '').trim()
if (!cleaned) continue
if (cleaned.includes(' - ') || cleaned.includes(' → ')) {
const parts = cleaned.split(/\s*(?:-|→)\s*/).map((p) => p.trim()).filter((p) => p)
for (const p of parts) if (isValid(p)) return p
continue
}
if (isValid(cleaned)) return cleaned
const m = cleaned.match(/([\u4e00-\u9fa5]{2,6})/)
if (m && isValid(m[1])) return m[1]
}
return ''
}
const cases = [
['**合金铝线采购** - 这是**电线电缆**的一种。', '', '老污染样本 — 长度>20 全段被丢'],
['**接地夹管束采购** - 是**管材**或电气配件', '', '同上 含"采购"项目词'],
['钢材', '钢材', '核心品类直接通过'],
['', '', '空'],
['**SVG/无功补偿装置** - 属于电气设备', '', '含英文字母拒绝'],
['钢管采购 — 设备配件', '', '含"采购"项目词'],
['\n\n钢材\n', '钢材', '前后空白被 trim'],
['让我分析一下,这条属于钢材', '钢材', '多行取尾部真品类'],
['钢材\n\n1. 钢材\n2. 管材\n3. 钢材', '钢材', '多行取首行'],
['电力金具', '电力金具', '4字新品类'],
['管材', '管材', '已有品类'],
['设备配件', '设备配件', '4字新品类'],
['标识标牌', '标识标牌', '4字'],
['工程服务', '工程服务', '4字'],
['杂品', '杂品', '2字'],
['中国电建', '', '黑名单单字"中国"包含时拒绝 — 但这是项目名前缀,要拆。修正'],
['配电箱', '配电箱', '3字'],
['阀门', '阀门', '2字'],
['一个非常长的品类名候选', '', '>6 字符拒绝'],
['防火门', '防火门', '3字'],
['建筑材料', '', '含项目词"材料"'],
['五金', '五金', '2字'],
]
let pass = 0
let fail = 0
for (const [input, expected, desc] of cases) {
const got = sanitize(input)
const ok = got === expected
if (ok) pass++; else fail++
const flag = ok ? '✓' : '✗'
console.log(` ${flag} ${desc}`)
if (!ok) {
console.log(` input: ${JSON.stringify(input)}`)
console.log(` expected: ${JSON.stringify(expected)}`)
console.log(` got: ${JSON.stringify(got)}`)
}
}
console.log(`\n${pass}/${pass + fail} passed`)
process.exit(fail === 0 ? 0 : 1)

View File

@ -107,7 +107,7 @@ def parse_list_page(html, type_label):
"detail_url": BASE_URL + a_tag.get("href", ""), "detail_url": BASE_URL + a_tag.get("href", ""),
"notice_id": re.search(r"/(\d+)\.jhtml", a_tag.get("href", "")).group(1) if re.search(r"/(\d+)\.jhtml", a_tag.get("href", "")) else "", "notice_id": re.search(r"/(\d+)\.jhtml", a_tag.get("href", "")).group(1) if re.search(r"/(\d+)\.jhtml", a_tag.get("href", "")) else "",
"type": type_label, "type": type_label,
"publish_time": "", "publish_date": "",
"deadline": "", "deadline": "",
"status": "", "status": "",
} }
@ -120,7 +120,7 @@ def parse_list_page(html, type_label):
if "Gray" in cls: if "Gray" in cls:
# 发布日期 # 发布日期
if re.match(r"\d{4}-\d{2}-\d{2}", text): if re.match(r"\d{4}-\d{2}-\d{2}", text):
notice["publish_time"] = text notice["publish_date"] = text
elif "Green" in cls: elif "Green" in cls:
# 正在报名 # 正在报名
notice["status"] = text.strip("[]") notice["status"] = text.strip("[]")
@ -132,7 +132,7 @@ def parse_list_page(html, type_label):
em_tag = span.find("em", class_="Red") em_tag = span.find("em", class_="Red")
if em_tag: if em_tag:
em_text = em_tag.get_text(strip=True) em_text = em_tag.get_text(strip=True)
dl_match = re.search(r"文件(?:发售|下载)截止[::](.+)", em_text) dl_match = re.search(r"文件下载截止[::](.+)", em_text)
if dl_match: if dl_match:
notice["deadline"] = dl_match.group(1).strip() notice["deadline"] = dl_match.group(1).strip()
@ -173,39 +173,6 @@ def save_state(state_path, state):
json.dump(state, f, ensure_ascii=False, indent=2) json.dump(state, f, ensure_ascii=False, indent=2)
def load_accumulated_state(output_base, state_filename, today_str):
"""加载历史状态实现跨天去重:合并所有历史状态文件中的已下载记录"""
accumulated = {}
last_run_date = None
state_dir = os.path.join(output_base, today_str)
today_state = load_state(os.path.join(state_dir, state_filename))
if today_state:
for k, v in today_state.items():
if k != "lastRun":
accumulated[k] = v
last_run = today_state.get("lastRun")
if last_run:
last_run_date = last_run[:10]
try:
for i in range(1, 31):
d = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join(output_base, d, state_filename)
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
prev = json.load(f)
except:
continue
for k, v in prev.items():
if k != "lastRun" and k not in accumulated:
accumulated[k] = v
if k == "lastRun" and not last_run_date:
last_run_date = str(v)[:10]
except:
pass
return accumulated, last_run_date
def generate_markdown(notices, output_dir, date_range, keyword=""): def generate_markdown(notices, output_dir, date_range, keyword=""):
"""生成公告清单 Markdown""" """生成公告清单 Markdown"""
lines = [ lines = [
@ -228,7 +195,7 @@ def generate_markdown(notices, output_dir, date_range, keyword=""):
deadline_display = n['deadline'] if n['deadline'] else '-' deadline_display = n['deadline'] if n['deadline'] else '-'
lines.extend([ lines.extend([
f"## {i}. {n['title']}", f"## {i}. {n['title']}",
f"- **发布日期**: {n['publish_time']}", f"- **发布日期**: {n['publish_date']}",
f"- **公告类型**: {n['type']}", f"- **公告类型**: {n['type']}",
f"- **状态**: {status_display}", f"- **状态**: {status_display}",
f"- **文件下载截止**: {deadline_display}", f"- **文件下载截止**: {deadline_display}",
@ -258,21 +225,15 @@ def run():
parser.add_argument("--all-types", action="store_true", help="抓取所有公告类型") parser.add_argument("--all-types", action="store_true", help="抓取所有公告类型")
parser.add_argument("--force", action="store_true", help="强制重新抓取") parser.add_argument("--force", action="store_true", help="强制重新抓取")
parser.add_argument("--keyword", default="", help="关键词过滤(匹配标题)") parser.add_argument("--keyword", default="", help="关键词过滤(匹配标题)")
parser.add_argument("--max-pages", type=int, default=10, help="最大翻页数(0=不限制,默认10页≈160条)") parser.add_argument("--max-pages", type=int, default=0, help="最大翻页数(0=不限制)")
args = parser.parse_args() args = parser.parse_args()
today = date.today() today = date.today()
today_str = today.strftime("%Y-%m-%d")
# 加载历史已下载记录(跨天去重)
accumulated_state, last_run_date = load_accumulated_state(OUTPUT_BASE, "cnncecp_downloaded.json", today_str)
if args.from_date: if args.from_date:
date_from = datetime.strptime(args.from_date, "%Y-%m-%d").date() date_from = datetime.strptime(args.from_date, "%Y-%m-%d").date()
elif not args.force and last_run_date:
date_from = datetime.strptime(last_run_date, "%Y-%m-%d").date()
else: else:
date_from = today date_from = today - timedelta(days=7)
if args.to_date: if args.to_date:
date_to = datetime.strptime(args.to_date, "%Y-%m-%d").date() date_to = datetime.strptime(args.to_date, "%Y-%m-%d").date()
@ -286,12 +247,12 @@ def run():
notice_types = list(NOTICE_TYPES.keys()) if args.all_types else DEFAULT_TYPES notice_types = list(NOTICE_TYPES.keys()) if args.all_types else DEFAULT_TYPES
# 输出目录 # 输出目录
output_dir = os.path.join(OUTPUT_BASE, today_str) output_dir = os.path.join(OUTPUT_BASE, today.strftime("%Y-%m-%d"))
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
# 状态文件(使用累积状态做去重) # 状态文件
state_file = os.path.join(output_dir, "cnncecp_downloaded.json") state_file = os.path.join(output_dir, "cnncecp_downloaded.json")
state = accumulated_state state = load_state(state_file)
all_notices = [] all_notices = []
new_count = 0 new_count = 0
@ -345,7 +306,7 @@ def run():
type_total += 1 type_total += 1
# 日期过滤 # 日期过滤
if not match_date_range(notice.get("publish_time", ""), date_from, date_to): if not match_date_range(notice["publish_date"], date_from, date_to):
type_filtered += 1 type_filtered += 1
filtered_count += 1 filtered_count += 1
continue continue
@ -368,7 +329,7 @@ def run():
state[notice_id] = { state[notice_id] = {
"title": notice["title"], "title": notice["title"],
"type": notice["type"], "type": notice["type"],
"publish_time": notice["publish_time"], "publish_date": notice["publish_date"],
"deadline": notice["deadline"], "deadline": notice["deadline"],
"status": notice["status"], "status": notice["status"],
"detail_url": notice["detail_url"], "detail_url": notice["detail_url"],
@ -391,8 +352,7 @@ def run():
print(f" ✅ {label} 完成: 新增 {type_new} | 跳过 {type_skip} | 过滤 {type_filtered}") print(f" ✅ {label} 完成: 新增 {type_new} | 跳过 {type_skip} | 过滤 {type_filtered}")
print() print()
# 保存状态(含 lastRun 时间戳,用于下次增量爬取) # 保存状态
state["lastRun"] = datetime.now().isoformat()
save_state(state_file, state) save_state(state_file, state)
# 生成清单 # 生成清单

View File

@ -25,26 +25,16 @@ const http = require('http');
const crypto = require('crypto'); const crypto = require('crypto');
// ========== 配置区 ========== // ========== 配置区 ==========
const KEYWORD = '水电十一局';
// 支持命令行传入 --keyword(例如: node fetch-v1.2.js --keyword 电缆) // 公告类型:可按需增减。支持的类型值见 API 返回的 announcementType 字段
const args = process.argv.slice(2) const TYPES = ['招采公告', '资格预审公告', '采购预告', '中标结果公示'];
const keywordIdx = args.indexOf('--keyword') // 可选:留空则从上次抓取日期继续;有值则从指定日期开始
const KEYWORD = (keywordIdx >= 0 && args[keywordIdx + 1]) ? args[keywordIdx + 1] : '' const START_DATE = '';
// 公告类型:与 API 返回的 announcementType 字段值严格一致
const TYPES = ['招采公告', '变更公告'];
// 可选:留空则从上次抓取日期继续;有值则从指定日期开始(支持环境变量覆盖)
const START_DATE = process.env.START_DATE || '';
const END_DATE = new Date().toISOString().split('T')[0]; // 今天 const END_DATE = new Date().toISOString().split('T')[0]; // 今天
// 跨天首次运行(state 为空)时回溯 N 天, 避免错过前一天后半天发布的公告
const BACKFILL_DAYS = 3;
const OUTPUT_DIR = path.join(process.env.HOME, 'desktop/招标公告', END_DATE); const OUTPUT_DIR = path.join(process.env.HOME, 'desktop/招标公告', END_DATE);
const PDF_DIR = path.join(OUTPUT_DIR, 'pdf'); // PDF 附件 const PDF_DIR = path.join(OUTPUT_DIR, 'pdf'); // PDF 附件
const HTML_DIR = path.join(OUTPUT_DIR, 'html'); // HTML 转换(待安装转换技能) const HTML_DIR = path.join(OUTPUT_DIR, 'html'); // HTML 转换(待安装转换技能)
// 每个关键词使用独立的状态文件,避免多关键词共用状态导致后续关键词跳过 const STATE_FILE = path.join(OUTPUT_DIR, 'downloaded.json'); // 已下载记录
const STATE_FILE = KEYWORD
? path.join(OUTPUT_DIR, `downloaded_${KEYWORD}.json`)
: path.join(OUTPUT_DIR, 'downloaded.json');
const LOG_FILE = path.join(OUTPUT_DIR, 'fetch.log'); // 运行日志 const LOG_FILE = path.join(OUTPUT_DIR, 'fetch.log'); // 运行日志
const CONCURRENCY = 3; // 并发下载数 const CONCURRENCY = 3; // 并发下载数
const RETRY_TIMES = 3; const RETRY_TIMES = 3;
@ -107,10 +97,9 @@ function request(url, options = {}, retries = RETRY_TIMES) {
}; };
const req = protocol.request(reqOptions, (res) => { const req = protocol.request(reqOptions, (res) => {
const chunks = []; let data = '';
res.on('data', chunk => chunks.push(chunk)); res.on('data', chunk => data += chunk);
res.on('end', () => { res.on('end', () => {
const data = Buffer.concat(chunks).toString('utf8');
if (res.statusCode === 301 || res.statusCode === 302) { if (res.statusCode === 301 || res.statusCode === 302) {
const location = res.headers.location; const location = res.headers.location;
if (location.startsWith('http')) { if (location.startsWith('http')) {
@ -231,54 +220,6 @@ async function runWithConcurrency(tasks, limit) {
return results; return results;
} }
// ---------- 地址提取 ----------
const PROVINCES = [
'北京', '天津', '上海', '重庆',
'河北', '山西', '辽宁', '吉林', '黑龙江',
'江苏', '浙江', '安徽', '福建', '江西', '山东',
'河南', '湖北', '湖南', '广东', '海南',
'四川', '贵州', '云南', '陕西', '甘肃', '青海',
'台湾', '内蒙古', '广西', '西藏', '宁夏', '新疆',
]
const TWO_CHAR_PROVS = PROVINCES.filter(p => p.length === 2)
const SPECIAL_PROVS = ['内蒙古', '广西', '新疆', '西藏', '宁夏']
function extractRegionFromTitle(title) {
if (!title) return ''
// 匹配 "XX省"、"XX市"(直辖市)、"XX自治区"
const m = title.match(/([\u4e00-\u9fff]{2,4}(?:省|市|自治区))/)
if (m) {
const region = m[1]
if (region.includes('新疆')) return '新疆'
if (region.includes('广西')) return '广西'
if (region.includes('内蒙古')) return '内蒙古'
if (region.includes('宁夏')) return '宁夏'
if (region.includes('西藏')) return '西藏'
if (['北京', '天津', '上海', '重庆'].some(c => region.includes(c))) return region.slice(0, 2)
if (region.endsWith('省') || region.endsWith('自治区')) return region.slice(0, 2)
return ''
}
// 直接匹配省份关键词
for (const prov of SPECIAL_PROVS) {
if (title.includes(prov)) return prov
}
for (const prov of TWO_CHAR_PROVS) {
if (title.includes(prov)) return prov
}
return ''
}
function extractCityFromTitle(title) {
if (!title) return ''
const m = title.match(/([\u4e00-\u9fff]{2,4}(?:市))/)
if (m) {
const city = m[1]
if (!['北京市', '天津市', '上海市', '重庆市'].includes(city)) return city
}
return ''
}
// ---------- 生成公告清单 ---------- // ---------- 生成公告清单 ----------
function buildAttachmentName(r, index) { function buildAttachmentName(r, index) {
@ -352,23 +293,12 @@ async function main() {
log(`========== 开始抓取: ${KEYWORD} ==========`); log(`========== 开始抓取: ${KEYWORD} ==========`);
// 确定起始日期:START_DATE 为空时退缩到上次运行的前 1 天(重叠 24 小时,覆盖 cron 间隔内漏抓的公告) // 确定起始日期(差量:上次运行日期 或 配置的 START_DATE)
// state.downloaded 通过 systemId 去重,重叠不会造成重复入库 const sinceDate = START_DATE || (state.lastRun ? state.lastRun.split('T')[0] : null) || END_DATE;
let sinceDate = START_DATE || (state.lastRun ? state.lastRun.split('T')[0] : null) || (() => {
const d = new Date(Date.now() - BACKFILL_DAYS * 86400 * 1000);
return d.toISOString().split('T')[0];
})();
// 增加 1 天回溯,覆盖 lastRun 之后但在 cron 间隔内发布的公告
if (!START_DATE && state.lastRun) {
const yesterday = new Date(Date.now() - 86400 * 1000).toISOString().split('T')[0];
sinceDate = yesterday;
}
if (START_DATE) { if (START_DATE) {
log(`指定日期范围: ${START_DATE} ~ ${END_DATE}`); log(`指定日期范围: ${START_DATE} ~ ${END_DATE}`);
} else if (state.lastRun) { } else if (state.lastRun) {
log(`增量模式(含 1 天回溯): ${sinceDate} ~ ${END_DATE}`); log(`差量模式: 自 ${state.lastRun.substring(0, 10)} 以来新增`);
} else if (sinceDate !== END_DATE) {
log(`跨天补抓: ${sinceDate} ~ ${END_DATE}`);
} else { } else {
log(`首次运行: ${sinceDate} ~ ${END_DATE}`); log(`首次运行: ${sinceDate} ~ ${END_DATE}`);
} }
@ -402,11 +332,9 @@ async function main() {
log(`共获取 ${stats.total} 条公告(分 ${page} 页)`); log(`共获取 ${stats.total} 条公告(分 ${page} 页)`);
// 过滤目标公告类型(由 TYPES 配置定义),且仅取货物类 // 过滤目标公告类型(由 TYPES 配置定义)
const caigou = allAnnouncements.filter(r => const caigou = allAnnouncements.filter(r => TYPES.includes(r.announcementType));
TYPES.includes(r.announcementType) && r.titleTypeName === '货物类' log(`其中 ${TYPES.join('、')}: ${caigou.length} 条`);
);
log(`其中 ${TYPES.join('、')}(货物类): ${caigou.length} 条`);
// 生成公告清单(所有本次抓到的招采公告,无论是否已下载) // 生成公告清单(所有本次抓到的招采公告,无论是否已下载)
const listMd = generateAnnouncementList(caigou, sinceDate, END_DATE); const listMd = generateAnnouncementList(caigou, sinceDate, END_DATE);
@ -435,7 +363,7 @@ async function main() {
if (!picUrl) { if (!picUrl) {
stats.skipNoAttachment++; stats.skipNoAttachment++;
log(`跳过(无附件): ${r.title}`, 'WARN'); log(`跳过(无附件): ${r.title}`, 'WARN');
state.downloaded[key] = { id: r.id, title: r.title, skipped: true, deadline: r.submissionDeadline || '', publishTime: r.publishTime || '', time: new Date().toISOString() }; state.downloaded[key] = { id: r.id, title: r.title, skipped: true, deadline: r.submissionDeadline || '', time: new Date().toISOString() };
continue; continue;
} }
@ -448,20 +376,15 @@ async function main() {
tasks.push(async () => { tasks.push(async () => {
log(`下载 [${shortName}]: ${fname}`); log(`下载 [${shortName}]: ${fname}`);
// 从标题中提取省份(详细地址由后端调用 extract_address_from_pdf.py 从 PDF 正文提取)
const region = extractRegionFromTitle(r.title) || ''
const regionDetail = extractCityFromTitle(r.title) || ''
try { try {
await downloadFile(picUrl, fpath); await downloadFile(picUrl, fpath);
const size = fs.statSync(fpath).size; const size = fs.statSync(fpath).size;
state.downloaded[key] = { id: r.id, title: r.title, deadline: r.submissionDeadline || '', publishTime: r.publishTime || '', path: `pdf/${fname}`, size, time: new Date().toISOString(), region, regionDetail }; state.downloaded[key] = { id: r.id, title: r.title, deadline: r.submissionDeadline || '', path: `pdf/${fname}`, size, time: new Date().toISOString() };
stats.success++; stats.success++;
log(` ✅ ${fname} (${size} bytes) region=${region || '-'} addr=${regionDetail || '-'}`); log(` ✅ ${fname} (${size} bytes)`);
} catch (err) { } catch (err) {
stats.failed++; stats.failed++;
state.downloaded[key] = { id: r.id, title: r.title, deadline: r.submissionDeadline || '', publishTime: r.publishTime || '', failed: true, error: err.message, time: new Date().toISOString(), region, regionDetail }; state.downloaded[key] = { id: r.id, title: r.title, deadline: r.submissionDeadline || '', failed: true, error: err.message, time: new Date().toISOString() };
log(` ❌ ${fname}: ${err.message}`, 'ERROR'); log(` ❌ ${fname}: ${err.message}`, 'ERROR');
} }
}); });

View File

@ -1,35 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
新矿集团供应链平台 公告抓取工具 新矿集团供应链平台 公告抓取工具
数据源:http://120.70.100.128:8096/(新站,2026-10 切换) 数据源:https://xkgroup.kdcloud.com/isv/q5m9/dist/srmportal.html
- 纯服务端渲染(layui),无 JSON API,抓 HTML 列表页解析 API:getPortalNoticeData (REST, 无需登录即可访问 guest 数据)
- 列表页:/front/notice/cgxx?category=bid|procure|auction(GET 表单分页,pageNo 从 0 起,每页 10 条)
- 详情页:/front/index/v_notice_view?id=<32hex>(含正文 + 附件 PDF 直链)
板块类型标签全集: 抓取类型:
bid 招标公告 / 变更&澄清公告 / 资格预审公告 / 中标候选人公示 / 中标公告 / 废标公告 C - 寻源公告(核心采购公告)
procure 采购公告 / 变更公告 / 结果公示 / 其他公告 D - 流标公告
auction 竞价公告 / 竞价流拍 B - 结果公示
默认抓取"商机类 + 流标类"(对应旧站 C 寻源 + D 流标语义):
商机类 = 招标公告/采购公告/竞价公告/资格预审公告
流标类 = 废标公告/竞价流拍
--all-types 额外抓结果/其他类(中标候选人公示/中标公告/变更&澄清/结果公示/其他公告)
用法: 用法:
python3 fetch-xkgroup.py # 差量更新(默认回溯到上次运行/7 天内) python3 fetch-xkgroup.py # 差量更新(默认抓取昨天的寻源公告)
python3 fetch-xkgroup.py --all-types # 抓取所有公告类型 python3 fetch-xkgroup.py --all-types # 抓取所有公告类型
python3 fetch-xkgroup.py --from 2026-10-01 # 指定起始日期 python3 fetch-xkgroup.py --from 2026-05-01 # 指定起始日期
python3 fetch-xkgroup.py --force # 强制重新抓取(不跳过已下载) python3 fetch-xkgroup.py --force # 强制重新抓取(不跳过已下载)
python3 fetch-xkgroup.py --keyword 水泥 # 按关键词过滤(标题/发布单位) python3 fetch-xkgroup.py --keyword 水泥 # 按关键词过滤
python3 fetch-xkgroup.py --max-pages 10 # 每板块最大翻页数(默认 5 页 = 50 条)
状态文件 xkgroup_downloaded.json 格式与旧版一致,CrawlerService.runGenericScript 依赖字段:
id/title/org/publish_time/detail_url/amount/deadline/region/region_detail/downloaded_at
""" """
import argparse import argparse
import html as html_lib
import json import json
import os import os
import re import re
@ -43,126 +31,93 @@ except ImportError:
sys.exit(1) sys.exit(1)
# ============ 配置区 ============ # ============ 配置区 ============
BASE_URL = "http://120.70.100.128:8096" BASE_URL = "https://xkgroup.kdcloud.com/kapi/app/srm/srmwebapi"
LIST_URL = f"{BASE_URL}/front/notice/cgxx" ACCOUNT_ID = "2066845847806639104"
PAGE_SIZE = 10 # 新站固定每页 10 条 PAGE_SIZE = 50
MAX_PAGES_DEFAULT = 5 # 每板块默认最多翻 5 页(日增量远小于此,历史回填可加大)
# 板块与类型标签 # 公告类型映射
CATEGORIES = ["bid", "procure", "auction"] NOTICE_TYPES = {
"C": "寻源公告",
# 商机类 + 流标类(默认抓取,对应旧版 C+D) "D": "流标公告",
BUSINESS_TAGS = {"招标公告", "采购公告", "竞价公告", "资格预审公告"} "B": "结果公示",
FLOWN_TAGS = {"废标公告", "竞价流拍"} }
# 结果/其他类(--all-types 才抓) DEFAULT_TYPES = ["C"] # 默认只抓取寻源公告
RESULT_TAGS = {"中标候选人公示", "中标公告", "变更&澄清公告", "变更公告", "结果公示", "其他公告"}
# 多次询价标题正则 (匹配 二次/三次/四次/.../九次)
RETRY_PATTERN = re.compile(r"[二三四五六七八九]次")
# 发布组织白名单 (新矿供应链平台关注的发布方)
ORG_WHITELIST = (
"新疆宝地矿业股份有限公司",
"新疆火烧云铅锌矿有限责任公司",
)
OUTPUT_BASE = os.path.expanduser("~/desktop/招标公告") OUTPUT_BASE = os.path.expanduser("~/desktop/招标公告")
HEADERS = { HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept": "text/html,application/xhtml+xml,*/*", "Accept": "application/json, text/plain, */*",
"Referer": f"{BASE_URL}/", "Referer": "https://xkgroup.kdcloud.com/isv/q5m9/dist/srmportal.html",
} }
# ============ 抓取层(新站 HTML 解析) ============ # ============ 核心函数 ============
def fetch_list_page(category, page_no, timeout=30): def fetch_notices(notice_type, page=1, size=PAGE_SIZE):
"""抓取列表页 HTML(GET 表单,pageNo 从 0 起)""" """调用 API 获取公告列表"""
resp = requests.get( params = {
LIST_URL, "page": page,
params={"category": category, "pageNo": page_no}, "size": size,
headers=HEADERS, "type": notice_type,
timeout=timeout, "apiname": "getPortalNoticeData",
) "methodname": "getPortalNoticeData",
"accountId": ACCOUNT_ID,
}
resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=30)
resp.raise_for_status() resp.raise_for_status()
return resp.text return resp.json()
def parse_list_html(html_text): def fetch_all_notices(notice_type):
"""解析列表页 tbody#listTbody 中的公告条目 """翻页获取指定类型的所有公告"""
返回 [{"id", "url_path", "title", "tag", "publish_date", "org"}]
"""
items = []
tbody_m = re.search(r'<tbody id="listTbody">([\s\S]*?)</tbody>', html_text)
rows = re.split(r"<tr>", tbody_m.group(1)) if tbody_m else []
for row in rows[1:]: # 第一段是 <thead> 残留
link_m = re.search(
r'<a href="(/front/index/v_notice_view\?id=([a-f0-9]{32})[^"]*)"[^>]*title="([^"]+)"', row
)
if not link_m:
continue
tag_m = re.search(r'<span class="tag[^"]*">([^<]+)</span>', row)
date_m = re.search(r"<td>\s*(20\d{2}-\d{2}-\d{2})\s*</td>", row)
tds = re.findall(r"<td>([^<]*)</td>", row)
items.append(
{
"id": link_m.group(2),
"url_path": html_lib.unescape(link_m.group(1)),
"title": html_lib.unescape(link_m.group(3)).strip(),
"tag": tag_m.group(1).strip() if tag_m else "",
"publish_date": date_m.group(1) if date_m else "",
"org": tds[-1].strip() if tds else "",
}
)
return items
def fetch_all_notices(category, date_from, max_pages):
"""翻页抓取指定板块的公告列表(按发布时间倒序,翻到早于 date_from 即停)"""
all_items = [] all_items = []
for page_no in range(max_pages): page = 1
html_text = fetch_list_page(category, page_no)
items = parse_list_html(html_text) while True:
if not items: data = fetch_notices(notice_type, page=page)
if not data.get("success"):
print(f" ❌ API 请求失败: {data.get('errorCode', 'unknown')}")
break break
items = data.get("data", {}).get("noticedata", [])
total = data.get("data", {}).get("total", 0)
all_items.extend(items) all_items.extend(items)
print(f" 第 {page_no + 1} 页: {len(items)} 条")
# 倒序排列:本页最旧一条都早于起始日期,无需继续翻页 total_page = (total + PAGE_SIZE - 1) // PAGE_SIZE
dates = [it["publish_date"] for it in items if it["publish_date"]] print(f" 第 {page}/{total_page} 页: {len(items)} 条")
if dates and max(dates) < str(date_from):
if len(items) < PAGE_SIZE or page >= total_page:
break break
page += 1
return all_items return all_items
def fetch_detail(notice_id, timeout=30): def normalize_notice(item, notice_type_label):
"""抓取详情页,返回 (html_text, body_text, attach_list) """统一公告数据格式"""
notice_id = item.get("noticeId", "")
pub_time = item.get("publishtime", "")
body_text 为去标签后的正文纯文本(供金额/截止/地区提取), # 解析状态
attach_list 为详情页附件直链列表(PDF/Word/Excel)。 status = item.get("status", "")
""" norm_status = status if status else "未知"
url = f"{BASE_URL}/front/index/v_notice_view?id={notice_id}"
resp = requests.get(url, headers=HEADERS, timeout=timeout)
resp.raise_for_status()
html_text = resp.text
cleaned = re.sub(r"<script[\s\S]*?</script>|<style[\s\S]*?</style>", " ", html_text)
body_text = html_lib.unescape(re.sub(r"<[^>]+>", " ", cleaned))
body_text = re.sub(r"[ \t\r]+", " ", body_text)
attach_list = re.findall(r'href="(https?://[^"]+\.(?:pdf|docx?|xlsx?)[^"]*)"', cleaned)
return html_text, body_text, attach_list
return {
def download_attachment(url, save_path, timeout=60): "id": notice_id,
"""下载附件文件(PDF/Word/Excel 直链)""" "title": item.get("noticename", ""),
resp = requests.get(url, headers={"User-Agent": HEADERS["User-Agent"]}, timeout=timeout, stream=True) "org": item.get("orgname", ""),
resp.raise_for_status() "org_number": item.get("orgnumber", ""),
with open(save_path, "wb") as f: "type": notice_type_label,
for chunk in resp.iter_content(chunk_size=65536): "type_code": item.get("type", ""),
f.write(chunk) "status": norm_status,
"publish_time": pub_time,
"detail_url": item.get("url", ""),
# ============ 业务过滤与字段提取(沿用旧版) ============ "mobile_url": item.get("mobileurl", ""),
"source": "新矿集团",
}
def match_date_range(publish_time_str, date_from, date_to): def match_date_range(publish_time_str, date_from, date_to):
@ -195,63 +150,27 @@ def save_state(state_path, state):
json.dump(state, f, ensure_ascii=False, indent=2) json.dump(state, f, ensure_ascii=False, indent=2)
def load_accumulated_state(output_base, state_filename, today_str):
"""加载历史状态实现跨天去重:合并所有历史状态文件中的已下载记录"""
accumulated = {}
last_run_date = None
state_dir = os.path.join(output_base, today_str)
# 先加载今日状态
today_state = load_state(os.path.join(state_dir, state_filename))
if today_state:
for k, v in today_state.items():
if k != "lastRun":
accumulated[k] = v
last_run = today_state.get("lastRun")
if last_run:
last_run_date = last_run[:10]
# 扫描历史目录(最近 30 天),合并之前下载的记录
try:
for i in range(1, 31):
d = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
path = os.path.join(output_base, d, state_filename)
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
prev = json.load(f)
except:
continue
for k, v in prev.items():
if k != "lastRun" and k not in accumulated:
accumulated[k] = v
if k == "lastRun" and not last_run_date:
last_run_date = str(v)[:10]
except:
pass
return accumulated, last_run_date
def generate_markdown(notices, output_dir, date_range): def generate_markdown(notices, output_dir, date_range):
"""生成公告清单 Markdown""" """生成公告清单 Markdown"""
lines = [ lines = [
f"# 新矿集团供应链平台 公告清单\n", f"# 新矿集团供应链平台 公告清单\n",
f"**抓取时间**: {datetime.now().strftime('%Y-%m-%d %H:%M')} | **日期范围**: {date_range}", f"**抓取时间**: {datetime.now().strftime('%Y-%m-%d %H:%M')} | **日期范围**: {date_range}",
f"**公告总数**: {len(notices)} 条\n", f"**公告总数**: {len(notices)} 条\n",
f"> 数据来源:{BASE_URL} | 平台:新矿集团供应链平台(新站)\n", f"> 数据来源:{BASE_URL} | 平台:新矿集团供应链平台\n",
"---\n", "---\n",
] ]
for i, n in enumerate(notices, 1): for i, n in enumerate(notices, 1):
lines.extend( lines.extend([
[
f"## {i}. {n['title']}", f"## {i}. {n['title']}",
f"- **发布单位**: {n['org']}", f"- **发布组织**: {n['org']}",
f"- **类型**: {n['type']}", f"- **类型**: {n['type']}",
f"- **状态**: {n['status']}",
f"- **发布时间**: {n['publish_time']}", f"- **发布时间**: {n['publish_time']}",
f"- **详情**: [查看]({n['detail_url']})", f"- **详情**: [查看]({n['detail_url']})",
"", "",
"---\n", "---\n",
] ])
)
content = "\n".join(lines) content = "\n".join(lines)
date_str = date_range.replace("~", "-") date_str = date_range.replace("~", "-")
@ -265,127 +184,143 @@ def generate_markdown(notices, output_dir, date_range):
return filepath return filepath
def extract_deadline_from_text(text): def run():
"""从公告正文中提取截止日期(采购文件获取截止时间)""" parser = argparse.ArgumentParser(description="新矿集团供应链平台 公告抓取")
if not text: parser.add_argument("--from", dest="from_date", help="起始日期 YYYY-MM-DD")
return "" parser.add_argument("--to", dest="to_date", help="截止日期 YYYY-MM-DD")
# 模式1: "起至2026年6月22日19:00(北京时间)止" → 取截止日期 parser.add_argument("--all-types", action="store_true", help="抓取所有公告类型")
m = re.search(r"起至(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2}):(\d{2})", text) parser.add_argument("--force", action="store_true", help="强制重新抓取")
if m: parser.add_argument("--keyword", default="", help="关键词过滤")
return f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}" args = parser.parse_args()
# 模式2: "获取时间:...至2026-06-22 19:00"
m = re.search(r"至\s*(\d{4})-(\d{1,2})-(\d{1,2})\s+\d{1,2}:\d{2}", text)
if m:
return f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}"
# 模式3: "截止时间:2026年6月22日" 或 "截止日期:2026-06-22"
for pat in [
r"截止[时间日期]*[::]\s*(\d{4})年(\d{1,2})月(\d{1,2})日",
r"截止[时间日期]*[::]\s*(\d{4})-(\d{1,2})-(\d{1,2})",
r"递交[文件]*截止[时间日期]*[::]\s*(\d{4})年(\d{1,2})月(\d{1,2})日",
r"递交[文件]*截止[时间日期]*[::]\s*(\d{4})-(\d{1,2})-(\d{1,2})",
]:
m = re.search(pat, text)
if m:
return f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}"
return ""
today = date.today()
# 全国省份/直辖市/自治区 if args.from_date:
PROVINCES = [ date_from = datetime.strptime(args.from_date, "%Y-%m-%d").date()
'北京', '天津', '上海', '重庆', else:
'河北', '山西', '辽宁', '吉林', '黑龙江', date_from = today - timedelta(days=7) # 默认抓取最近 7 天
'江苏', '浙江', '安徽', '福建', '江西', '山东',
'河南', '湖北', '湖南', '广东', '海南',
'四川', '贵州', '云南', '陕西', '甘肃', '青海',
'台湾', '内蒙古', '广西', '西藏', '宁夏', '新疆',
]
TWO_CHAR_PROVS = [p for p in PROVINCES if len(p) == 2]
SPECIAL_PROVS = ['内蒙古', '广西', '新疆', '西藏', '宁夏']
if args.to_date:
date_to = datetime.strptime(args.to_date, "%Y-%m-%d").date()
else:
date_to = today
def extract_province_best(text): date_range = f"{date_from}~{date_to}"
"""从任意文本中提取最可能的省份(先关键词匹配,避免正则在多字省份上截断)""" days_span = (date_to - date_from).days + 1
if not text:
return ''
text_clean = text.replace(' ', '')
# 1. 特殊多字省份(优先,避免"新疆维吾尔自治区"被截)
for sp in SPECIAL_PROVS:
if sp in text_clean:
return sp
# 2. 直辖市
for dc in ['北京', '天津', '上海', '重庆']:
if dc in text_clean:
return dc
# 3. "XX省"(如"陕西省"、"广东省")
m = re.search(r'([\u4e00-\u9fff]{2}省)', text)
if m:
return m.group(1)[:2]
# 4. 两字省份关键词
for p in TWO_CHAR_PROVS:
if p in text_clean:
return p
return ''
# 公告类型
notice_types = list(NOTICE_TYPES.keys()) if args.all_types else DEFAULT_TYPES
def extract_city_from_text(text): # 输出目录
"""从文本中提取城市名(XX市)""" output_dir = os.path.join(OUTPUT_BASE, today.strftime("%Y-%m-%d"))
if not text: os.makedirs(output_dir, exist_ok=True)
return ''
m = re.search(r'([\u4e00-\u9fff]{2,4}(?:市))', text)
if m:
city = m.group(1)
if city not in ['北京市', '天津市', '上海市', '重庆市']:
return city
return ''
# 子目录:html(源码) + pdf(转存)
html_dir = os.path.join(output_dir, "html")
pdf_dir = os.path.join(output_dir, "pdf")
os.makedirs(html_dir, exist_ok=True)
os.makedirs(pdf_dir, exist_ok=True)
def extract_region_from_text(text, org=""): # 状态文件
"""从公告正文或公司名中提取地区(省份)""" state_file = os.path.join(output_dir, "xkgroup_downloaded.json")
if not text and not org: state = load_state(state_file)
return ""
# 1. 从正文找项目/交货/施工地点
if text:
for pat in [
r"(?:项目|交货|送货|施工|到货|供货|工程|实施|服务)[地址地点]*[::]\s*([\u4e00-\u9fff]{2,8}(?:省|市|自治区))",
r"(?:地[址点]|所在地区)[::]\s*([\u4e00-\u9fff]{2,8}(?:省|市|自治区))",
r"位\s*于\s*([\u4e00-\u9fff]{2,4}(?:省|市|自治区))",
r"(?:项目|工程)\s*(?:位于|所在地|所在)[:::\s]*([\u4e00-\u9fff]{2,4}(?:省|市|自治区))",
]:
m = re.search(pat, text)
if m:
region = m.group(1)
for sp in SPECIAL_PROVS:
if sp in region:
return sp
return region[:2] # 取前两个字
# 2. 从全文扫描省份关键词
prov = extract_province_best(text)
if prov:
return prov
# 3. 从公司名推断
if org:
org_prov = extract_province_best(org)
if org_prov:
return org_prov
return ""
all_notices = []
new_count = 0
skip_count = 0
filtered_count = 0
def extract_region_detail_from_text(text): print(f"===== 新矿集团供应链平台 公告抓取 =====")
"""从公告正文中提取详细收货/交货地址""" print(f"日期范围: {date_from} ~ {date_to} ({days_span}天)")
if not text: print(f"公告类型: {', '.join([NOTICE_TYPES.get(t, t) for t in notice_types])}")
return "" print()
for pat in [
r"(?:交货|收货|送货|项目|施工|到货|供货|工程|实施|服务)[地址地点]*[::]\s*([\u4e00-\u9fff\d\-—\s]{6,60}(?:路|街|道|巷|号|村|镇|乡|区|县|市|大厦|楼|园|区|厂|公司|仓库))(?:\n|$)", for ntype in notice_types:
r"(?:地[址点])[::]\s*([\u4e00-\u9fff\d\-—\s]{6,60}(?:路|街|道|巷|号|村|镇|乡|区|县|市|大厦|楼|园|区|厂|公司|仓库))(?:\n|$)", label = NOTICE_TYPES.get(ntype, ntype)
r"建设地点[::]\s*([\u4e00-\u9fff\d\-—\s]{6,60}?)(?:\n|$)", print(f"[{label}] 正在获取...")
r"项目地点[::]\s*([\u4e00-\u9fff\d\-—\s]{6,60}?)(?:\n|$)",
]: items = fetch_all_notices(ntype)
m = re.search(pat, text) print(f" ✅ API 返回 {len(items)} 条")
if m:
addr = m.group(1).strip() type_new = 0
if len(addr) >= 6: type_skip = 0
return addr for item in items:
return "" notice = normalize_notice(item, label)
# 日期过滤
if not match_date_range(notice["publish_time"], date_from, date_to):
filtered_count += 1
continue
# 关键词过滤
if args.keyword:
title = notice["title"]
org = notice["org"]
if args.keyword not in title and args.keyword not in org:
filtered_count += 1
continue
notice_id = notice["id"]
# 去重
if not args.force and notice_id in state:
skip_count += 1
type_skip += 1
continue
all_notices.append(notice)
state[notice_id] = {
"title": notice["title"],
"org": notice["org"],
"publish_time": notice["publish_time"],
"type": notice["type"],
"detail_url": notice["detail_url"],
"downloaded_at": datetime.now().isoformat(),
}
new_count += 1
type_new += 1
print(f" 新增: {type_new} | 跳过: {type_skip}")
print()
# 保存状态
save_state(state_file, state)
# 为缺少金额的记录从详情页提取
extract_amounts_for_existing(state, output_dir)
# 生成清单
if all_notices:
generate_markdown(all_notices, output_dir, date_range)
# 详情页转存 PDF + HTML(新公告 + 已有状态中缺文件的)
all_pending = list(all_notices)
if not args.force:
for notice_id, info in state.items():
pdf_key = info.get("pdf", "")
html_key = info.get("html", "")
pdf_ok = pdf_key and os.path.exists(os.path.join(output_dir, pdf_key))
html_ok = html_key and os.path.exists(os.path.join(output_dir, html_key))
if pdf_ok and html_ok:
continue
all_pending.append({
"id": notice_id,
"title": info.get("title", ""),
"org": info.get("org", ""),
"publish_time": info.get("publish_time", ""),
"type": info.get("type", ""),
"mobile_url": f"https://xkgroup.kdcloud.com/mobile.html?form=mobsp_quonotice_view&billid={notice_id}&userId=guest&accountId=2066845847806639104",
"detail_url": f"https://xkgroup.kdcloud.com/index.html?formId=quo_notice&noticeId={notice_id}&userId=guest&accountId=2066845847806639104",
})
if all_pending:
download_pdfs(all_pending, output_dir, html_dir, pdf_dir, state, state_file, force=args.force)
else:
print(" ℹ️ 指定日期范围内无新公告")
print(f"\n========== 完成 ==========")
print(f"✅ 新增: {new_count} | ⏩ 跳过: {skip_count} | 🔍 日期过滤: {filtered_count}")
print(f"📁 保存目录: {output_dir}")
def extract_amount_from_text(text): def extract_amount_from_text(text):
@ -418,219 +353,179 @@ def extract_amount_from_text(text):
return "" return ""
def process_details(notices, output_dir, html_dir, pdf_dir, state, state_file): def extract_amounts_for_existing(state, output_dir):
"""对新公告做详情页增强:存 HTML 源码 + 提取金额/截止/地区 + 下载附件 PDF """为已抓取但缺少金额的公告,访问详情页提取金额"""
新站详情页是纯 SSR HTML,requests 直接可取(不再依赖 Playwright)。
单条失败仅告警,不阻断。
"""
if not notices:
return
print(f"\n[详情增强] {len(notices)} 条新公告,提取金额/截止/地区 + 存档...")
success = 0
for i, notice in enumerate(notices):
notice_id = notice["id"]
print(f" [{i+1}/{len(notices)}] {notice['title'][:40]}...", end=" ", flush=True)
try: try:
html_text, body_text, attach_list = fetch_detail(notice_id) import asyncio
from playwright.async_api import async_playwright
except ImportError:
print(" ⚠️ Playwright 未安装,跳过金额提取")
return
# 筛选缺少金额的记录
pending = []
for notice_id, info in state.items():
if notice_id == "lastRun":
continue
if info.get("amount"):
continue
pending.append(notice_id)
if not pending:
print(" ✅ 所有记录已有金额信息")
return
print(f"\n[金额提取] {len(pending)} 条记录缺少金额,正在从详情页提取...")
async def _run():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
success = 0
for i, notice_id in enumerate(pending):
info = state[notice_id]
url = f"https://xkgroup.kdcloud.com/mobile.html?form=mobsp_quonotice_view&billid={notice_id}&userId=guest&accountId=2066845847806639104"
print(f" [{i+1}/{len(pending)}] {info.get('title', '')[:40]}...", end=" ", flush=True)
try:
page = await browser.new_page()
page.set_default_timeout(20000)
await page.goto(url, wait_until="domcontentloaded", timeout=20000)
await page.wait_for_timeout(2000)
text = await page.evaluate("() => document.body.innerText")
await page.close()
amount = extract_amount_from_text(text)
if amount:
state[notice_id]["amount"] = amount
success += 1
print(f"✅ {amount}")
else:
print("⏭️ 未找到金额")
except Exception as e:
print(f"❌ {e}")
await browser.close()
return success
import asyncio
try:
success = asyncio.run(_run())
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
success = loop.run_until_complete(_run())
loop.close()
if success:
save_state(os.path.join(output_dir, "xkgroup_downloaded.json"), state)
print(f" 💰 金额提取完成: {success} 条")
def download_pdfs(notices, output_dir, html_dir, pdf_dir, state=None, state_file=None, force=False):
"""将新矿集团公告详情页转存为 PDF"""
try:
import asyncio
from playwright.async_api import async_playwright
except ImportError:
print(" ⚠️ Playwright 未安装,跳过 PDF 下载")
return
print("\n[PDF] 正在下载详情页...")
async def _run():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
success = 0
fail = 0
for i, notice in enumerate(notices):
notice_id = notice.get("id", "")
# 手机版页面含完整内容,桌面版只有摘要
pdf_url = notice.get("mobile_url", "") or notice.get("detail_url", "")
if not notice_id or not pdf_url:
continue
pub_date = notice.get("publish_time", "")[:10] pub_date = notice.get("publish_time", "")[:10]
safe_title = re.sub(r"[\\/:*?\"<>|]", "_", notice["title"][:40]) safe_title = re.sub(r"[\\/:*?\"<>|]", "_", notice["title"][:40])
base_name = f"{pub_date}_{notice_id}_{safe_title}" base_name = f"{pub_date}_{notice_id}_{safe_title}"
html_name = f"{base_name}.html" html_name = f"{base_name}.html"
with open(os.path.join(html_dir, html_name), "w", encoding="utf-8") as f: pdf_name = f"{base_name}.pdf"
f.write(html_text) html_path = os.path.join(html_dir, html_name)
state[notice_id]["html"] = f"html/{html_name}" pdf_path = os.path.join(pdf_dir, pdf_name)
rel_pdf = f"pdf/{pdf_name}"
# 字段提取 if not force and os.path.exists(pdf_path):
amount = extract_amount_from_text(body_text) continue
deadline = extract_deadline_from_text(body_text)
region = extract_region_from_text(body_text, notice.get("org", "")) or state[notice_id].get("region", "") print(f" [{i+1}/{len(notices)}] {notice['title'][:40]}...", end=" ", flush=True)
region_detail = extract_region_detail_from_text(body_text)
if amount:
state[notice_id]["amount"] = amount
if deadline:
state[notice_id]["deadline"] = deadline
if region:
state[notice_id]["region"] = region
if region_detail:
state[notice_id]["region_detail"] = region_detail
# 附件下载(取第一个 PDF/文档)
if attach_list:
ext = ".pdf" if ".pdf" in attach_list[0].lower() else os.path.splitext(attach_list[0])[1] or ".pdf"
pdf_name = f"{base_name}{ext}"
try: try:
download_attachment(attach_list[0], os.path.join(pdf_dir, pdf_name)) page = await browser.new_page()
state[notice_id]["pdf"] = f"pdf/{pdf_name}" page.set_default_timeout(30000)
except Exception as e: await page.goto(pdf_url, wait_until="domcontentloaded", timeout=25000)
print(f"⚠️ 附件下载失败({e})", end=" ") await page.wait_for_timeout(2000)
print(f"✅ amount={amount or '-'} deadline={deadline or '-'} region={region or '-'}") # 提取页面文本,解析金额
page_text = await page.evaluate("() => document.body.innerText")
amount = extract_amount_from_text(page_text)
if amount and state is not None and notice_id in state:
state[notice_id]["amount"] = amount
# 保存 HTML 源码到 html/ 目录(供 AI 阅读)
html_source = await page.content()
with open(html_path, "w", encoding="utf-8") as f:
f.write(html_source)
# 获取实际内容高度(SPA 可能 overflow:hidden,scrollHeight 不准确)
content_height = await page.evaluate(
"() => Math.max("
" document.documentElement.scrollHeight,"
" document.body.scrollHeight,"
" ...Array.from(document.querySelectorAll('body *'))"
" .map(el => el.getBoundingClientRect().bottom)"
" .filter(v => v < 50000),"
" 1000"
")"
)
await page.pdf(
path=pdf_path,
width="1440px",
height=f"{content_height + 50}px",
print_background=True,
)
await page.close()
html_size = os.path.getsize(html_path)
pdf_size = os.path.getsize(pdf_path)
print(f"✅ PDF:{pdf_size//1024}KB HTML:{html_size//1024}KB")
success += 1 success += 1
except Exception as e:
print(f"❌ {e}")
# 边处理边落盘,中断不丢已处理的记录 # 更新状态文件,记录路径
if state is not None and state_file and notice_id in state:
state[notice_id]["pdf"] = rel_pdf
state[notice_id]["html"] = f"html/{html_name}"
with open(state_file, "w", encoding="utf-8") as f: with open(state_file, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2) json.dump(state, f, ensure_ascii=False, indent=2)
if success:
print(f" 📦 详情增强完成: {success}/{len(notices)} 条")
def run():
parser = argparse.ArgumentParser(description="新矿集团供应链平台 公告抓取(新站 SSR 版)")
parser.add_argument("--from", dest="from_date", help="起始日期 YYYY-MM-DD")
parser.add_argument("--to", dest="to_date", help="截止日期 YYYY-MM-DD")
parser.add_argument("--all-types", action="store_true", help="抓取所有公告类型(含中标/结果/变更类)")
parser.add_argument("--force", action="store_true", help="强制重新抓取")
parser.add_argument("--keyword", default="", help="关键词过滤(标题/发布单位)")
parser.add_argument("--max-pages", dest="max_pages", type=int, default=MAX_PAGES_DEFAULT,
help=f"每板块最大翻页数(默认 {MAX_PAGES_DEFAULT})")
args = parser.parse_args()
today = date.today()
# 加载历史已下载记录(跨天去重)
today_str = today.strftime("%Y-%m-%d")
accumulated_state, last_run_date = load_accumulated_state(OUTPUT_BASE, "xkgroup_downloaded.json", today_str)
if args.from_date:
date_from = datetime.strptime(args.from_date, "%Y-%m-%d").date()
elif not args.force and last_run_date:
date_from = datetime.strptime(last_run_date, "%Y-%m-%d").date()
else:
date_from = today - timedelta(days=7)
if args.to_date:
date_to = datetime.strptime(args.to_date, "%Y-%m-%d").date()
else:
date_to = today
date_range = f"{date_from}~{date_to}"
days_span = (date_to - date_from).days + 1
# 允许的类型集合:默认商机类+流标类,--all-types 全部
allowed_tags = BUSINESS_TAGS | FLOWN_TAGS
if args.all_types:
allowed_tags = allowed_tags | RESULT_TAGS
# 输出目录
output_dir = os.path.join(OUTPUT_BASE, today_str)
os.makedirs(output_dir, exist_ok=True)
# 子目录:html(源码) + pdf(附件转存)
html_dir = os.path.join(output_dir, "html")
pdf_dir = os.path.join(output_dir, "pdf")
os.makedirs(html_dir, exist_ok=True)
os.makedirs(pdf_dir, exist_ok=True)
# 状态文件(使用累积状态做去重)
state_file = os.path.join(output_dir, "xkgroup_downloaded.json")
state = accumulated_state
all_notices = []
new_count = 0
skip_count = 0
filtered_count = 0
print(f"===== 新矿集团供应链平台 公告抓取(新站 {BASE_URL}) =====")
print(f"日期范围: {date_from} ~ {date_to} ({days_span}天)")
print(f"类型范围: {'全部' if args.all_types else '商机类+流标类'}")
print()
for category in CATEGORIES:
print(f"[{category}] 正在获取...")
try:
items = fetch_all_notices(category, date_from, args.max_pages)
except Exception as e: except Exception as e:
print(f" ❌ 板块 {category} 抓取失败: {e}") print(f"❌ {e}")
continue fail += 1
print(f" ✅ 列表解析 {len(items)} 条")
type_new = 0 await browser.close()
type_skip = 0 return success, fail
for item in items:
tag = item["tag"]
# 类型过滤
if tag and tag not in allowed_tags:
filtered_count += 1
continue
# 日期过滤 import asyncio
if not match_date_range(item["publish_date"], date_from, date_to): try:
filtered_count += 1 success, fail = asyncio.run(_run())
continue if success:
print(f" 📄 PDF 下载完成: {success} 成功" + (f", {fail} 失败" if fail else ""))
# 关键词过滤 except RuntimeError:
if args.keyword: loop = asyncio.new_event_loop()
if args.keyword not in item["title"] and args.keyword not in item["org"]: asyncio.set_event_loop(loop)
filtered_count += 1 success, fail = loop.run_until_complete(_run())
continue loop.close()
if success:
# 多次询价标题过滤 (二次/三次/四次...) print(f" 📄 PDF 下载完成: {success} 成功" + (f", {fail} 失败" if fail else ""))
if not RETRY_PATTERN.search(item["title"]):
filtered_count += 1
continue
# 发布组织白名单(新站"发布单位"列常为代理机构,采购人名多在标题里,故标题+单位联合匹配)
org_text = item["org"] + item["title"]
if not any(org_name in org_text for org_name in ORG_WHITELIST):
filtered_count += 1
continue
notice_id = item["id"]
# 去重
if not args.force and notice_id in state:
skip_count += 1
type_skip += 1
continue
notice = {
"id": notice_id,
"title": item["title"],
"org": item["org"],
"type": tag or "公告",
"status": "",
"publish_time": item["publish_date"],
"detail_url": f"{BASE_URL}{item['url_path']}",
"mobile_url": "",
"source": "新矿集团",
}
# 从标题/组织名初步提取地区(详情页提取时会补充/覆盖)
title_region = extract_region_from_text(notice["title"], notice["org"])
all_notices.append(notice)
state[notice_id] = {
"title": notice["title"],
"org": notice["org"],
"publish_time": notice["publish_time"],
"type": notice["type"],
"detail_url": notice["detail_url"],
"downloaded_at": datetime.now().isoformat(),
"region": title_region,
}
new_count += 1
type_new += 1
print(f" 新增: {type_new} | 跳过: {type_skip}")
print()
# 保存状态(含 lastRun 时间戳,用于下次增量爬取)
state["lastRun"] = datetime.now().isoformat()
save_state(state_file, state)
# 生成清单
if all_notices:
generate_markdown(all_notices, output_dir, date_range)
# 详情页增强:HTML 存档 + 金额/截止/地区提取 + 附件 PDF 转存
process_details(all_notices, output_dir, html_dir, pdf_dir, state, state_file)
print(f"\n========== 完成 ==========")
print(f"✅ 新增: {new_count} | ⏩ 跳过: {skip_count} | 🔍 日期过滤: {filtered_count}")
print(f"📁 保存目录: {output_dir}")
if __name__ == "__main__": if __name__ == "__main__":