Compare commits
40 Commits
main
...
feature/zo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
029c629b3b | ||
|
|
855d4c3f00 | ||
|
|
d6ad7e02c5 | ||
|
|
5a49a37235 | ||
|
|
eb15849168 | ||
|
|
f12722eed9 | ||
|
|
fd8b197853 | ||
|
|
587a09774e | ||
|
|
fc4a4e81d4 | ||
|
|
072696321e | ||
|
|
6964380de5 | ||
|
|
1dfe5ede67 | ||
|
|
68f6fc5c16 | ||
|
|
4d5521e365 | ||
|
|
ad7e739b83 | ||
|
|
9702d4b8ae | ||
|
|
0d2dce6b73 | ||
|
|
8a3c96598b | ||
|
|
a822d7782b | ||
|
|
1a92a3c095 | ||
|
|
4896453dfb | ||
|
|
a9ef6e952f | ||
|
|
ab764bbc6c | ||
|
|
f625dd0de1 | ||
|
|
58ac140802 | ||
|
|
761200a70c | ||
|
|
c1c7342621 | ||
|
|
3a72d6396c | ||
|
|
785db9277d | ||
|
|
d8d49f7e88 | ||
|
|
481d01c89f | ||
|
|
209f2e3d05 | ||
|
|
8d8945f92b | ||
|
|
44609be8c7 | ||
|
|
0a423af4d2 | ||
|
|
ae72966766 | ||
|
|
bd213eb5ec | ||
|
|
49f922444a | ||
|
|
672a4b41b2 | ||
|
|
4ab0040e55 |
8
.gitignore
vendored
8
.gitignore
vendored
@ -8,6 +8,7 @@ dist/
|
|||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
build/
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Local data
|
# Local data
|
||||||
*.db
|
*.db
|
||||||
@ -41,8 +42,15 @@ 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
291
AGENTS.md
@ -2,6 +2,48 @@
|
|||||||
|
|
||||||
语言:中文。所有对话、注释、文档、提交信息均使用中文。
|
语言:中文。所有对话、注释、文档、提交信息均使用中文。
|
||||||
|
|
||||||
|
## 🤝 协作流程标准(避免理解不一致造成返工)
|
||||||
|
|
||||||
|
**所有具体问题的修改,必须按这个流程执行:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用户描述需求(业务场景、看到的问题、截图、日志)
|
||||||
|
↓
|
||||||
|
2. 协作者反问 7 个技术维度(场景/触发/重要程度/数据/边界/样式/部署)
|
||||||
|
↓
|
||||||
|
3. 对齐范围:用户确认 + 协作者列技术方案
|
||||||
|
↓
|
||||||
|
4. 实施 + 验证
|
||||||
|
↓
|
||||||
|
5. 用户看效果,反馈或继续下一项
|
||||||
|
```
|
||||||
|
|
||||||
|
### 协作者必须问的 7 个技术维度
|
||||||
|
|
||||||
|
| 维度 | 问什么 |
|
||||||
|
|------|--------|
|
||||||
|
| **场景** | 哪个页面/哪个模块?用户路径是什么? |
|
||||||
|
| **触发条件** | 什么时候出现?手动还是自动?频率? |
|
||||||
|
| **重要程度** | 阻塞核心流程 / 体验优化 / 锦上添花? |
|
||||||
|
| **数据** | 影响多少数据?要不要数据迁移? |
|
||||||
|
| **边界** | 空数据/异常情况怎么处理? |
|
||||||
|
| **样式** | 改动大吗?要不要出截图对比? |
|
||||||
|
| **生产部署** | 改完要不要立即部署?还是攒一起? |
|
||||||
|
|
||||||
|
### 原则
|
||||||
|
|
||||||
|
- **不擅自开干**:未对齐范围前不做实现
|
||||||
|
- **不预设方案**:用户描述 ≠ 协作者的解决方案,先问清楚
|
||||||
|
- **不省略验证**:改完必须跑端到端测试,不只是"代码看起来对"
|
||||||
|
- **不静默改大动作**:涉及数据库结构、生产配置、依赖升级等要明确告知
|
||||||
|
|
||||||
|
### 例外(不需要问的简单场景)
|
||||||
|
|
||||||
|
- 拼写错误、明显笔误
|
||||||
|
- 单文件 typo 修复
|
||||||
|
- 用户明确说"按你判断做"
|
||||||
|
- 紧急 bug 修复(事后补问)
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -43,10 +85,13 @@ 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) | `ssh devbox` + PM2, **不在 Mac 本地** |
|
| **3002** | devbox MCP server (Node.js 版 `product_network_mcp`) | `ssh devbox` + PM2, **不在 Mac 本地** |
|
||||||
|
| **8095** | devbox MCP server (Java 版 `industry-mcp-server` Docker) | `ssh devbox` + `docker ps` 看 `industry-mcp-server`, **不在 Mac 本地** |
|
||||||
|
|
||||||
注意:**3002 是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。
|
注意:**3002 / 8095 都是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。
|
||||||
打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002)。
|
打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002/8095)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用。
|
||||||
|
|
||||||
|
> ⚠️ 2026-07-15 实测: 平台助手本地 `next dev` 偶尔会 fallback 到 3000 端口(被 `admin_new` Vite 项目抢 3001 时),以实际启动后终端打印的端口为准。`AdminPage.tsx:1000` 默认填的 endpoint 仍是 `http://100.111.84.16:3002/mcp`(Node 版)。
|
||||||
|
|
||||||
AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP server 地址 (填到 ConfigTab 的"endpoint" 字段), 不是 web 入口。
|
AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP server 地址 (填到 ConfigTab 的"endpoint" 字段), 不是 web 入口。
|
||||||
|
|
||||||
@ -80,7 +125,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 |
|
||||||
@ -94,7 +139,72 @@ 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
|
User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Report, AuditLog, CrawlerConfig(含 keywords/categories + cron schedule), Inquiry, Product(含 supplier/imageUrl), GeneratedImage, **ProductCategory**(品类字典,AI 关键词反哺用)
|
||||||
|
|
||||||
|
## 品类方案 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` | 部署时自动同步上述脚本到生产 |
|
||||||
|
|
||||||
## 采集器关键事实
|
## 采集器关键事实
|
||||||
|
|
||||||
@ -109,7 +219,13 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
|
|||||||
3. 无需改代码,`runGenericScript()` 自动推断脚本和 JSON 文件路径
|
3. 无需改代码,`runGenericScript()` 自动推断脚本和 JSON 文件路径
|
||||||
- `CrawlerConfig` 新增 `sourceName`(线索来源显示名)和 `scriptPath`(脚本文件名,可选,默认从 type 推断)
|
- `CrawlerConfig` 新增 `sourceName`(线索来源显示名)和 `scriptPath`(脚本文件名,可选,默认从 type 推断)
|
||||||
|
|
||||||
## 生产部署(腾讯云 124.222.182.242 / direv.cloud)
|
## 生产部署(腾讯云 172.81.241.109 / 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`)
|
||||||
@ -120,6 +236,7 @@ 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`
|
||||||
@ -139,6 +256,7 @@ 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 | 去水印+裁剪+白底合成 |
|
||||||
|
|
||||||
@ -149,21 +267,34 @@ User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Repor
|
|||||||
```
|
```
|
||||||
平台助手 NestJS (4000)
|
平台助手 NestJS (4000)
|
||||||
↓ IntegrationService.callTool()
|
↓ IntegrationService.callTool()
|
||||||
↓ MCP 协议 (Streamable HTTP)
|
↓ MCP 协议 (Streamable HTTP, POST 方法)
|
||||||
↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key)
|
↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key)
|
||||||
renren-mcp-server (Node.js, devbox:3002)
|
renren-mcp-server
|
||||||
|
├─ Node.js 版 (devbox:3002, product_network_mcp)
|
||||||
|
└─ Java 版 (devbox:8095, industry-mcp-server Docker 容器)
|
||||||
↓ 直调 Java 业务方法
|
↓ 直调 Java 业务方法
|
||||||
↓
|
↓
|
||||||
product_network 7 个 Java 服务
|
product_network 7 个 Java 服务
|
||||||
```
|
```
|
||||||
|
|
||||||
注: MCP server 端口 3002 是 **devbox 开发机** 上的端口, 不是平台助手本地端口 (3001/4000)。
|
注: MCP server 端口 **3002(Node)和 8095(Java Docker)都是 devbox 开发机上的端口**, 不是平台助手本地端口 (3001/4000)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用, 申请时确认走哪一套。
|
||||||
|
|
||||||
|
### MCP 端点要求(重要!)
|
||||||
|
|
||||||
|
平台助手消费 MCP 时, 端点必须满足:
|
||||||
|
|
||||||
|
- **协议**: Streamable HTTP (`@modelcontextprotocol/sdk` 的 `StreamableHTTPClientTransport`)
|
||||||
|
- **方法**: **POST** (不是 GET). 如果 MCP server 实现要求 GET, 需要改 server 而不是 client
|
||||||
|
- **认证头**: 多租户 APIKey → `x-app-id` + `x-app-secret`, 由 devbox B2B 运营后台"集成管理 → 应用列表"申请
|
||||||
|
- **路径示例**: `http://host:port/mcp` (注意末尾 `/mcp`)
|
||||||
|
|
||||||
|
前端 "MCP 对接配置 → 新增对接" 弹窗的 MCP 地址字段下方有提示文案提醒这些要求。
|
||||||
|
|
||||||
### 设计原则
|
### 设计原则
|
||||||
|
|
||||||
- **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4)
|
- **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4)
|
||||||
- **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销)
|
- **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销)
|
||||||
- **APIKey 认证**:Header `x-api-key`,AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex)
|
- **APIKey 认证**:Header `x-app-id` + `x-app-secret`(多租户, 替代旧的 `x-api-key`), AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex)
|
||||||
- **公司账号代理**:每客户 1 个 `sys_user` (user_type=99)
|
- **公司账号代理**:每客户 1 个 `sys_user` (user_type=99)
|
||||||
|
|
||||||
### 关键代码路径
|
### 关键代码路径
|
||||||
@ -171,11 +302,15 @@ 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) |
|
| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool)+ **session 管理 + charset 修复** |
|
||||||
| `apps/api/src/integration/integration.controller.ts` | REST API(CRUD + test + connect) |
|
| `apps/api/src/integration/integration.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 |
|
| `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool + **可选 overrides 参数** |
|
||||||
| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面) |
|
| `apps/api/src/lead/lead.controller.ts` | `POST /api/leads/:id/publish` 新增可选 body |
|
||||||
|
| `apps/api/src/lead/material-extractor.service.ts` | **W37 新增** 物料清单提取(发布时实时调 MiniMax) |
|
||||||
|
| `apps/api/src/lead/lead.module.ts` | 注册 `MaterialExtractorService` |
|
||||||
|
| `apps/web/src/components/InquiryPage.tsx` | **发布前确认弹窗**(对接文档 §5.2) |
|
||||||
|
| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面)+ MCP 协议提示文案 |
|
||||||
|
|
||||||
### 使用流程
|
### 使用流程
|
||||||
|
|
||||||
@ -191,13 +326,50 @@ 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 端点
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
@ -220,6 +392,97 @@ await client.callTool({ name: 'publish_rfq', arguments: { title, ... } })
|
|||||||
- 连接状态存储在数据库,服务重启后需重新连接
|
- 连接状态存储在数据库,服务重启后需重新连接
|
||||||
- 发布失败降级为本地模式(生成假 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 模式
|
||||||
|
|||||||
448
HANDOVER.md
Normal file
448
HANDOVER.md
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
# 平台助手 — 项目交接文档
|
||||||
|
|
||||||
|
**交接日期:** 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 发布**,接手时建议先跑通这条链路的本地/生产实测,再动其他模块。祝顺利!
|
||||||
402
PRD-智能比价助手.md
Normal file
402
PRD-智能比价助手.md
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
# 智能比价助手 - 产品需求文档 (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 组合枚举性能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档结束** — 如有疑问可参考源码实现细节。建议先跑通核心算法单元测试,再集成前后端。
|
||||||
@ -28,6 +28,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
-- 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");
|
||||||
@ -28,6 +28,9 @@ 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?
|
||||||
@ -227,3 +230,12 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 8 * * 1-5', totalRuns: 286, successRuns: 275, failedRuns: 11, keywords: '["电缆","防火门","配电箱","钢材"]' },
|
{ 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://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://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://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,6 +262,33 @@ 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()
|
||||||
|
|||||||
@ -13,6 +13,7 @@ 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({
|
||||||
@ -31,6 +32,7 @@ import { HealthController } from './common/health.controller'
|
|||||||
CrawlerModule,
|
CrawlerModule,
|
||||||
AuditLogModule,
|
AuditLogModule,
|
||||||
IntegrationModule,
|
IntegrationModule,
|
||||||
|
ProductCategoryModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { Controller, Get, Post, Put, Param, Body } from '@nestjs/common'
|
import { Controller, Get, Post, Put, Param, Body, Query } 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(private readonly crawlerService: CrawlerService) {}
|
constructor(
|
||||||
|
private readonly crawlerService: CrawlerService,
|
||||||
|
private readonly notesExtractor: NotesExtractorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll() {
|
findAll() {
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
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()],
|
imports: [PrismaModule, ScheduleModule.forRoot(), ProductCategoryModule],
|
||||||
controllers: [CrawlerController],
|
controllers: [CrawlerController],
|
||||||
providers: [CrawlerService],
|
providers: [CrawlerService, NotesExtractorService],
|
||||||
|
exports: [NotesExtractorService],
|
||||||
})
|
})
|
||||||
export class CrawlerModule {}
|
export class CrawlerModule {}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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')
|
||||||
@ -18,6 +19,8 @@ interface FetchResult {
|
|||||||
noticeId: string
|
noticeId: string
|
||||||
detailUrl: string
|
detailUrl: string
|
||||||
amount?: string
|
amount?: string
|
||||||
|
region?: string
|
||||||
|
regionDetail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -27,6 +30,7 @@ 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() {
|
||||||
@ -109,14 +113,21 @@ 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()
|
results = await this.runPowerChina(config)
|
||||||
} 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) {
|
|
||||||
if (!r.noticeId) continue
|
const validResults = results.filter(r => r.noticeId)
|
||||||
|
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: {
|
||||||
@ -128,7 +139,10 @@ 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: {
|
||||||
@ -142,17 +156,20 @@ export class CrawlerService implements OnModuleInit {
|
|||||||
amount: r.amount || '待确认',
|
amount: r.amount || '待确认',
|
||||||
status: '待复核',
|
status: '待复核',
|
||||||
level: 'C',
|
level: 'C',
|
||||||
category: '',
|
category: categoryMap.get(r.noticeId) || '',
|
||||||
region: '',
|
region: r.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 {
|
} catch (e: unknown) {
|
||||||
// 其他异常跳过
|
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: {
|
||||||
@ -172,52 +189,127 @@ export class CrawlerService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runPowerChina(): Promise<FetchResult[]> {
|
private async resolveKeywords(config: { categories?: string | null; keywords?: string | null }): Promise<string[]> {
|
||||||
|
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('中电建抓取脚本不存在')
|
||||||
}
|
}
|
||||||
|
|
||||||
execSync(`node "${scriptPath}"`, {
|
const keywords = await this.resolveKeywords(config)
|
||||||
encoding: 'utf-8',
|
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
|
||||||
timeout: 180000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const statePath = path.join(
|
const baseDir = path.join(process.env.HOME || '', 'desktop/招标公告', today)
|
||||||
process.env.HOME || '',
|
const seen = new Set<string>()
|
||||||
'desktop/招标公告',
|
const allResults: FetchResult[] = []
|
||||||
today,
|
|
||||||
'downloaded.json',
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!fs.existsSync(statePath)) return []
|
for (const kw of keywords) {
|
||||||
|
const kwFlag = kw ? ` --keyword "${kw}"` : ''
|
||||||
|
try {
|
||||||
|
this.logger.log(`执行关键词 [${kw || '(空)'}] 抓取脚本`)
|
||||||
|
execSync(`node "${scriptPath}"${kwFlag}`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
timeout: 180000,
|
||||||
|
})
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.logger.warn(`关键词 [${kw || '(空)'}] 抓取失败: ${(e as { stderr?: string; message?: string }).stderr || (e as { message?: string }).message}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
|
// 每个关键词有独立的状态文件: downloaded_<keyword>.json
|
||||||
const downloaded = state.downloaded || {}
|
const stateFileName = kw ? `downloaded_${kw}.json` : 'downloaded.json'
|
||||||
const results: FetchResult[] = []
|
const statePath = path.join(baseDir, stateFileName)
|
||||||
|
if (!fs.existsSync(statePath)) {
|
||||||
|
this.logger.warn(`关键词 [${kw || '(空)'}] 状态文件不存在: ${statePath}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, v] of Object.entries(downloaded)) {
|
try {
|
||||||
const info = v as { title?: string; skipped?: boolean; failed?: boolean; id?: string; deadline?: string }
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
|
||||||
if (info.skipped || info.failed) continue
|
const downloaded = state.downloaded || {}
|
||||||
const [systemId] = key.split('_')
|
this.logger.log(`关键词 [${kw || '(空)'}] 状态文件读取成功: ${Object.keys(downloaded).length} 条记录`)
|
||||||
const realId = info.id || systemId
|
|
||||||
results.push({
|
const outputDir = path.dirname(statePath)
|
||||||
source: '中电建',
|
const pdfDir = path.join(outputDir, 'pdf')
|
||||||
title: info.title || '',
|
|
||||||
organization: '中电建',
|
for (const [key, v] of Object.entries(downloaded)) {
|
||||||
publishTime: state.lastRun || '',
|
const info = v as { title?: string; skipped?: boolean; failed?: boolean; id?: string; deadline?: string; publishTime?: string; path?: string }
|
||||||
deadline: info.deadline || '',
|
if (info.skipped || info.failed) continue
|
||||||
noticeId: systemId,
|
const [systemId] = key.split('_')
|
||||||
detailUrl: `https://bid.powerchina.cn/notice/detail?id=${realId}`,
|
if (seen.has(systemId)) continue
|
||||||
})
|
seen.add(systemId)
|
||||||
|
|
||||||
|
const realId = info.id || systemId
|
||||||
|
|
||||||
|
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: '中电建',
|
||||||
|
title: info.title || '',
|
||||||
|
organization: '中电建',
|
||||||
|
publishTime: info.publishTime || state.lastRun || '',
|
||||||
|
deadline: info.deadline || '',
|
||||||
|
noticeId: systemId,
|
||||||
|
detailUrl: `https://bid.powerchina.cn/notice/detail?id=${realId}`,
|
||||||
|
region,
|
||||||
|
regionDetail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.logger.error(`关键词 [${kw || '(空)'}] 状态文件解析失败: ${(e as Error).message}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
this.logger.log(`抓取汇总: ${allResults.length} 条公告待入库`)
|
||||||
|
return allResults
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runGenericScript(config: { type: string; sourceName?: string | null; scriptPath?: string | null }): Promise<FetchResult[]> {
|
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)
|
||||||
@ -228,41 +320,59 @@ export class CrawlerService implements OnModuleInit {
|
|||||||
throw new Error(`抓取脚本不存在: ${scriptFile}`)
|
throw new Error(`抓取脚本不存在: ${scriptFile}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isJs = scriptFile.endsWith('.js')
|
const keywords = await this.resolveKeywords(config)
|
||||||
const cmd = isJs ? `node "${scriptPath}"` : `python3 "${scriptPath}"`
|
|
||||||
execSync(cmd, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
|
||||||
timeout: 180000,
|
|
||||||
})
|
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const statePath = path.join(
|
const baseDir = path.join(process.env.HOME || '', 'desktop/招标公告', today)
|
||||||
process.env.HOME || '',
|
const statePath = path.join(baseDir, jsonFile)
|
||||||
'desktop/招标公告',
|
|
||||||
today,
|
|
||||||
jsonFile,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!fs.existsSync(statePath)) return []
|
const isJs = scriptFile.endsWith('.js')
|
||||||
|
|
||||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
|
|
||||||
const results: FetchResult[] = []
|
const results: FetchResult[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const todayStr = new Date().toISOString().substring(0, 10)
|
||||||
|
|
||||||
for (const [noticeId, v] of Object.entries(state)) {
|
for (const kw of keywords) {
|
||||||
const info = v as { title?: string; org?: string; publish_time?: string; detail_url?: string; id?: string; deadline?: string; amount?: string }
|
const kwFlag = kw ? ` --keyword "${kw}"` : ''
|
||||||
if (noticeId === 'lastRun') continue
|
const cmd = isJs ? `node "${scriptPath}"${kwFlag}` : `python3 "${scriptPath}"${kwFlag}`
|
||||||
const leadAmount = info.amount || ''
|
try {
|
||||||
results.push({
|
execSync(cmd, {
|
||||||
source,
|
encoding: 'utf-8',
|
||||||
title: info.title || '',
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
organization: info.org || source,
|
timeout: 180000,
|
||||||
publishTime: info.publish_time || '',
|
})
|
||||||
deadline: info.deadline || '',
|
} catch {
|
||||||
noticeId: info.id || noticeId,
|
// 单个 keyword 失败不影响其他 keyword
|
||||||
detailUrl: info.detail_url || '',
|
continue
|
||||||
amount: leadAmount,
|
}
|
||||||
})
|
|
||||||
|
if (!fs.existsSync(statePath)) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'))
|
||||||
|
for (const [noticeId, v] of Object.entries(state)) {
|
||||||
|
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 }
|
||||||
|
// 只处理本次新增的记录(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({
|
||||||
|
source,
|
||||||
|
title: info.title || '',
|
||||||
|
organization: info.org || source,
|
||||||
|
publishTime: info.publish_time || '',
|
||||||
|
deadline: info.deadline || '',
|
||||||
|
noticeId: dedupeKey,
|
||||||
|
detailUrl: info.detail_url || '',
|
||||||
|
amount: info.amount || '',
|
||||||
|
region: info.region || '',
|
||||||
|
regionDetail: info.region_detail || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
202
apps/api/src/crawler/notes-extractor.service.ts
Normal file
202
apps/api/src/crawler/notes-extractor.service.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@ 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) {
|
||||||
@ -73,6 +75,58 @@ 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 {
|
||||||
@ -82,6 +136,7 @@ export class IntegrationService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.mcpClients.clear()
|
this.mcpClients.clear()
|
||||||
|
this.mcpSessionIds.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: {
|
async create(data: {
|
||||||
@ -162,6 +217,8 @@ 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' },
|
||||||
@ -174,11 +231,19 @@ 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 || []
|
||||||
|
|
||||||
@ -218,7 +283,9 @@ export class IntegrationService implements OnModuleDestroy {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.logger.log(`MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}`)
|
this.logger.log(
|
||||||
|
`MCP 连接成功: ${integration.name} (appId=${integration.appId}), ${tools.length} 个工具, companyUserId=${companyUserId || 'null'}, session=${this.mcpSessionIds.get(id)?.slice(0, 8) || 'none'}`,
|
||||||
|
)
|
||||||
|
|
||||||
return { success: true, tools }
|
return { success: true, tools }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -241,6 +308,8 @@ export class IntegrationService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
this.mcpClients.delete(id)
|
this.mcpClients.delete(id)
|
||||||
}
|
}
|
||||||
|
// 断开时清空 session, 下次 connect 强制重新走 initialize
|
||||||
|
this.clearSession(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -260,6 +329,7 @@ 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),
|
||||||
{
|
{
|
||||||
@ -269,6 +339,7 @@ export class IntegrationService implements OnModuleDestroy {
|
|||||||
'x-app-secret': appSecret,
|
'x-app-secret': appSecret,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
fetch: this.buildSessionAwareFetch('test'),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -373,27 +444,23 @@ 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 | null {
|
private parseToolResult<T>(result: any): T {
|
||||||
try {
|
if (!result) throw new Error('MCP 工具返回结果为空')
|
||||||
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]
|
||||||
if (item.type === 'text') {
|
if (item.type === 'text') {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(item.text) as T
|
return JSON.parse(item.text) as T
|
||||||
} catch {
|
} catch {
|
||||||
return item.text as unknown as T
|
return item.text as unknown as T
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result as T
|
|
||||||
} catch (e: any) {
|
|
||||||
this.logger.error(`解析 MCP tool 结果失败: ${e.message}`)
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return result as T
|
||||||
}
|
}
|
||||||
|
|
||||||
getClient(integrationId: string): Client | null {
|
getClient(integrationId: string): Client | null {
|
||||||
|
|||||||
31
apps/api/src/lead/lead-notes.controller.ts
Normal file
31
apps/api/src/lead/lead-notes.controller.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,8 +14,9 @@ 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 })
|
return this.leadService.findAll({ status, level, search, star, source, category, assignedTo, timeRange })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('stats')
|
@Get('stats')
|
||||||
@ -54,8 +55,8 @@ export class LeadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/publish')
|
@Post(':id/publish')
|
||||||
publish(@Param('id') id: string) {
|
publish(@Param('id') id: string, @Body() body?: { contactPhone?: string; contactName?: string; deliveryAddress?: string }) {
|
||||||
return this.leadService.publishToDirev(id)
|
return this.leadService.publishToDirev(id, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('batch-publish')
|
@Post('batch-publish')
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
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],
|
imports: [PrismaModule, IntegrationModule, CrawlerModule, ProductCategoryModule],
|
||||||
controllers: [LeadController],
|
controllers: [LeadController, LeadNotesController],
|
||||||
providers: [LeadService],
|
providers: [LeadService, MaterialExtractorService, TitleCompressorService],
|
||||||
exports: [LeadService],
|
exports: [LeadService],
|
||||||
})
|
})
|
||||||
export class LeadModule {}
|
export class LeadModule {}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
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 {
|
||||||
@ -9,9 +13,13 @@ 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 }) {
|
async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string; timeRange?: 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
|
||||||
@ -28,16 +36,40 @@ 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) where.category = params.category
|
if (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: [
|
orderBy: [{ publishTime: 'desc' }, { createdAt: 'desc' }],
|
||||||
{ level: 'asc' },
|
|
||||||
{ createdAt: 'desc' },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,7 +244,10 @@ export class LeadService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async publishToDirev(id: string) {
|
async publishToDirev(
|
||||||
|
id: string,
|
||||||
|
overrides?: { contactPhone?: string; contactName?: string; deliveryAddress?: string },
|
||||||
|
) {
|
||||||
const lead = await this.findOne(id)
|
const lead = await this.findOne(id)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
@ -226,8 +261,12 @@ export class LeadService {
|
|||||||
|
|
||||||
const publishArgs: any = {
|
const publishArgs: any = {
|
||||||
title: lead.title || '平台助手发布询价',
|
title: lead.title || '平台助手发布询价',
|
||||||
description: lead.summary || lead.title || '',
|
// W31 P1: 优先用 notes (老 GUI 6 条商务要点) 作为 description
|
||||||
|
// 备选 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) {
|
||||||
@ -250,17 +289,22 @@ 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 (lead.regionDetail) publishArgs.shippingAddress = lead.regionDetail
|
if (overrides?.deliveryAddress) publishArgs.deliveryAddress = overrides.deliveryAddress
|
||||||
else if (lead.region) publishArgs.shippingAddress = lead.region
|
else if (lead.regionDetail) publishArgs.deliveryAddress = lead.regionDetail
|
||||||
|
else if (lead.region) publishArgs.deliveryAddress = lead.region
|
||||||
|
|
||||||
if (lead.contact) publishArgs.contactName = lead.contact
|
// 联系人信息(弹窗用户可编辑覆盖,否则取线索数据,最后用占位)
|
||||||
if (lead.phone) publishArgs.contactPhone = lead.phone
|
publishArgs.contactName = overrides?.contactName || lead.contact || '待补充'
|
||||||
|
publishArgs.contactPhone = overrides?.contactPhone || lead.phone || '待补充'
|
||||||
|
|
||||||
|
// 企业级业务字段(按 MCP publish_rfq 对接文档 4.2 节字段类型和值)
|
||||||
publishArgs.inquiryParty = '郑州巨变网络科技有限公司'
|
publishArgs.inquiryParty = '郑州巨变网络科技有限公司'
|
||||||
publishArgs.freightFlag = '含运费'
|
publishArgs.freightFlag = 1 // 1=含运费(int,不要传字符串)
|
||||||
publishArgs.procurementType = '定量采购'
|
publishArgs.procurementType = 1 // 1=公开采购
|
||||||
publishArgs.invoiceType = '增值税专票'
|
publishArgs.invoiceType = 2 // 2=增值税专票
|
||||||
publishArgs.paymentMethod = '货到付款'
|
// paymentMethod 不传,后端会用企业默认配置(传了"23"反而会报"企业字典找不到")
|
||||||
|
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) {
|
||||||
@ -269,6 +313,38 @@ 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',
|
||||||
@ -292,9 +368,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(
|
// 识别常见错误模式,给出明确提示
|
||||||
`发布失败: B2B 平台返回了响应但未包含询价 ID。响应: ${JSON.stringify(payload).slice(0, 200)}`,
|
const diagnosis = this.diagnosePublishError(lead, payload)
|
||||||
)
|
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}`
|
||||||
@ -404,4 +480,54 @@ 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}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
280
apps/api/src/lead/material-extractor.service.ts
Normal file
280
apps/api/src/lead/material-extractor.service.ts
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)))
|
||||||
|
.split('\n')
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(l => l.length > 0)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
88
apps/api/src/lead/title-compressor.service.ts
Normal file
88
apps/api/src/lead/title-compressor.service.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
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 || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
231
apps/api/src/product-category/category-inference.service.ts
Normal file
231
apps/api/src/product-category/category-inference.service.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/api/src/product-category/product-category.controller.ts
Normal file
27
apps/api/src/product-category/product-category.controller.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/product-category/product-category.module.ts
Normal file
13
apps/api/src/product-category/product-category.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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 {}
|
||||||
83
apps/api/src/product-category/product-category.service.ts
Normal file
83
apps/api/src/product-category/product-category.service.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
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],
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
import { Injectable } from '@nestjs/common'
|
import { Injectable, Logger, ServiceUnavailableException } 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 {
|
||||||
constructor(private prisma: PrismaService) {}
|
private readonly logger = new Logger(ProductService.name)
|
||||||
|
|
||||||
|
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' } })
|
||||||
@ -36,10 +42,84 @@ export class ProductService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async batchPublish(ids: string[]) {
|
async batchPublish(ids: string[]) {
|
||||||
return this.prisma.product.updateMany({
|
const products = await this.prisma.product.findMany({
|
||||||
|
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) {
|
||||||
|
|||||||
@ -6,3 +6,24 @@ 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; }
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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'
|
||||||
@ -14,6 +16,7 @@ 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'
|
||||||
|
|
||||||
@ -38,20 +41,26 @@ export default function Home() {
|
|||||||
if (!user) return <LoginPage />
|
if (!user) return <LoginPage />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<ToastProvider>
|
||||||
<Sidebar current={page} onNavigate={setPage} user={user} onLogout={logout} />
|
<div className="flex min-h-screen">
|
||||||
<main className="flex-1 ml-64 p-8">
|
<Sidebar current={page} onNavigate={setPage} user={user} onLogout={logout} />
|
||||||
{page === 'overview' && <OverviewPage onNavigate={(p) => setPage(p as Page)} />}
|
<main className="flex-1 ml-64 p-8 flex flex-col">
|
||||||
{page === 'inquiry' && <InquiryPage onLeadDetail={handleLeadDetail} />}
|
<ErrorBoundary>
|
||||||
{page === 'image' && <ImagePage />}
|
{page === 'overview' && <OverviewPage onNavigate={(p) => setPage(p as Page)} />}
|
||||||
{page === 'product' && <ProductPage />}
|
{page === 'inquiry' && <InquiryPage onLeadDetail={handleLeadDetail} />}
|
||||||
{page === 'comparison' && <ComparisonPage />}
|
{page === 'image' && <ImagePage />}
|
||||||
{page === 'lead-detail' && <LeadDetailPage leadId={leadDetailId!} onNavigate={(p) => setPage(p as Page)} />}
|
{page === 'product' && <ProductPage />}
|
||||||
{page === 'report-center' && <ReportCenterPage />}
|
{page === 'comparison' && <ComparisonPage />}
|
||||||
{page === 'rule-center' && <RuleCenterPage />}
|
{page === 'lead-detail' && <LeadDetailPage leadId={leadDetailId!} onNavigate={(p) => setPage(p as Page)} />}
|
||||||
{page === 'system-monitor' && <SystemMonitorPage />}
|
{page === 'report-center' && <ReportCenterPage />}
|
||||||
{page.startsWith('admin-') && <AdminPage section={page.replace('admin-', '') as any} />}
|
{page === 'rule-center' && <RuleCenterPage />}
|
||||||
</main>
|
{page === 'system-monitor' && <SystemMonitorPage />}
|
||||||
</div>
|
{page.startsWith('admin-') && <AdminPage section={page.replace('admin-', '') as any} />}
|
||||||
|
</ErrorBoundary>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<IcpFooter />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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 ----
|
||||||
|
|
||||||
@ -56,18 +57,15 @@ const authHeaders = (): Record<string, string> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toastManager() {
|
function toastManager() {
|
||||||
const [toast, setToast] = useState('')
|
// Legacy wrapper - now delegates to useToast via context
|
||||||
const show = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
|
return { toast: '', show: (_msg: string) => {} }
|
||||||
return { toast, show }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage({ section }: { section: Tab }) {
|
export default function AdminPage({ section }: { section: Tab }) {
|
||||||
const { toast, show: showToast } = toastManager()
|
const { showToast } = useToast()
|
||||||
|
|
||||||
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>
|
||||||
@ -1002,6 +1000,16 @@ 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 () => {
|
||||||
@ -1328,10 +1336,19 @@ function ConfigTab({ showToast }: { showToast: (m: string) => void }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showIntegrationForm && (
|
{showIntegrationForm && (
|
||||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => { setShowIntegrationForm(false); setEditingIntegration(null) }}>
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4">
|
||||||
<div className="p-6 border-b border-border-subtle">
|
<div className="p-6 border-b border-border-subtle">
|
||||||
<h2 className="text-lg font-bold">{editingIntegration ? '编辑对接' : '新增对接'}</h2>
|
<div className="flex items-center justify-between">
|
||||||
|
<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>
|
||||||
@ -1350,6 +1367,9 @@ 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>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -45,7 +47,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 [toast, setToast] = useState('')
|
const { showToast } = useToast()
|
||||||
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)
|
||||||
|
|
||||||
@ -55,11 +57,6 @@ 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([
|
||||||
@ -134,7 +131,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 <div className="text-center py-20 text-on-surface-variant">加载中...</div>
|
if (loading) return <SkeletonTable rows={5} cols={6} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -629,12 +626,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
79
apps/web/src/components/ErrorBoundary.tsx
Normal file
79
apps/web/src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ErrorBoundary extends Component<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
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/web/src/components/IcpFooter.tsx
Normal file
14
apps/web/src/components/IcpFooter.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -4,6 +4,9 @@ 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
|
||||||
@ -45,8 +48,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)
|
||||||
@ -59,15 +62,13 @@ 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(setImages)
|
.then(data => {
|
||||||
|
setImages(Array.isArray(data) ? data : [])
|
||||||
|
})
|
||||||
|
.catch(() => setImages([]))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -289,7 +290,19 @@ export default function ImagePage() {
|
|||||||
}
|
}
|
||||||
}, [matchMap, productList, images])
|
}, [matchMap, productList, images])
|
||||||
|
|
||||||
if (loading) return <div className="text-center py-20 text-on-surface-variant">加载中...</div>
|
if (loading) return (
|
||||||
|
<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">
|
||||||
@ -646,23 +659,7 @@ export default function ImagePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Lightbox */}
|
{/* Preview Lightbox */}
|
||||||
{previewUrl && (
|
<Lightbox src={previewUrl} onClose={() => setPreviewUrl(null)} />
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef, useMemo } 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
|
||||||
@ -35,7 +38,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 || 'C',
|
level: raw.level ? raw.level + '级' : 'C级',
|
||||||
amount: raw.amount || '待确认',
|
amount: raw.amount || '待确认',
|
||||||
status: raw.status || '待复核',
|
status: raw.status || '待复核',
|
||||||
deadline: raw.deadline || '',
|
deadline: raw.deadline || '',
|
||||||
@ -58,9 +61,8 @@ function mapLead(raw: any): Lead {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['全部状态', '待复核', '待跟进', '跟进中', '已转询价', '已转协同', '已成交', '无效线索']
|
const STATUS_OPTIONS = ['全部状态', '待复核', '待跟进', '跟进中', '已转询价', '已转协同', '已成交', '无效线索']
|
||||||
const LEVEL_OPTIONS = ['全部级别', 'S级', 'A级', 'B级']
|
const LEVEL_OPTIONS = ['全部级别', 'S级', 'A级', 'B级', 'C级', 'D级']
|
||||||
const CAT_OPTIONS = ['全部品类', '防火门', '配电箱', '电线电缆', '阀门', '钢材', '涂料', '混凝土']
|
const SOURCE_OPTIONS = ['全部来源', '中电建', '新矿集团', '中核集团']
|
||||||
const SOURCE_OPTIONS = ['全部来源', 'PowerChina 招采', '新矿集团', '发改委审批', '行业资讯']
|
|
||||||
const PERSON_OPTIONS = ['全部人员', '郑钊', '赵主任', '运营管理员', '未分配']
|
const PERSON_OPTIONS = ['全部人员', '郑钊', '赵主任', '运营管理员', '未分配']
|
||||||
|
|
||||||
function getStatusClass(status: string) {
|
function getStatusClass(status: string) {
|
||||||
@ -96,6 +98,7 @@ 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
|
||||||
@ -105,22 +108,47 @@ 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 [toast, setToast] = useState('')
|
const { showToast } = useToast()
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
|
|
||||||
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
|
const [fSearch, setFSearch] = useState('')
|
||||||
|
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 (star?: string, status?: string, level?: string, cat?: string, source?: string, person?: string) => {
|
const loadLeads = useCallback(async (search?: string, star?: string, status?: string, level?: string, cat?: string, source?: string, person?: string, timeRange?: 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')
|
||||||
@ -128,11 +156,12 @@ 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)
|
if (level && level !== '全部级别') params.set('level', level.replace('级', ''))
|
||||||
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())
|
||||||
@ -144,10 +173,16 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { loadLeads(fStar, fStatus, fLevel, fCat, fSource, fPerson) }, [loadLeads, fStar, fStatus, fLevel, fCat, fSource, fPerson])
|
useEffect(() => {
|
||||||
|
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(fStar, fStatus, fLevel, fCat, fSource, fPerson)
|
await loadLeads(fSearch, fStar, fStatus, fLevel, fCat, fSource, fPerson, fTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPages = Math.ceil(filtered.length / pageSize)
|
const totalPages = Math.ceil(filtered.length / pageSize)
|
||||||
@ -193,6 +228,13 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,7 +254,15 @@ 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`, { method: 'POST' })
|
const res = await fetch(`${API}/${lead.id}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contactName: pubForm.contactName || undefined,
|
||||||
|
contactPhone: pubForm.contactPhone || undefined,
|
||||||
|
deliveryAddress: pubForm.deliveryAddress || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
lead.publish = '已发布'
|
lead.publish = '已发布'
|
||||||
success++
|
success++
|
||||||
@ -392,11 +442,9 @@ 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 => (
|
||||||
@ -407,6 +455,74 @@ 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>
|
||||||
@ -445,8 +561,7 @@ 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-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-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>
|
||||||
@ -454,9 +569,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={11} className="p-8 text-center text-sm text-on-surface-variant">加载中...</td></tr>
|
<tr><td colSpan={10} className="p-8"><SkeletonTable rows={5} cols={10} /></td></tr>
|
||||||
) : pageLeads.length === 0 ? (
|
) : pageLeads.length === 0 ? (
|
||||||
<tr><td colSpan={11} className="p-8 text-center text-sm text-on-surface-variant">暂无数据</td></tr>
|
<tr><td colSpan={10} 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 (
|
||||||
@ -476,8 +591,7 @@ 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 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 ${lead.cat ? 'bg-purple-50 text-purple-700' : 'bg-slate-100 text-slate-400'}`}>{lead.cat || '-'}</span></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">
|
||||||
@ -626,6 +740,33 @@ 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>
|
||||||
@ -635,117 +776,144 @@ export default function InquiryPage({ onLeadDetail }: { onLeadDetail?: (id: stri
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* S级甲方动态 Modal */}
|
{/* S级甲方动态 Modal */}
|
||||||
{showSModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowSModal(false)}>
|
open={showSModal}
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] max-h-[calc(100vh-120px)] overflow-y-auto transform" onClick={e => e.stopPropagation()}>
|
onClose={() => setShowSModal(false)}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
title="S级甲方动态"
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
subtitle="近7日动态 · 按时间倒序排列"
|
||||||
<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>
|
width="w-[520px]"
|
||||||
S级甲方动态
|
>
|
||||||
</h3>
|
<div className="space-y-3">
|
||||||
<button onClick={() => setShowSModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
{sSorted.map(l => (
|
||||||
<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>
|
<div key={l.id} className="p-3 rounded-lg bg-slate-50 hover:bg-blue-50 transition-colors cursor-pointer border border-slate-100">
|
||||||
</button>
|
<div className="flex justify-between items-start">
|
||||||
</div>
|
<div className="flex items-start gap-3">
|
||||||
<div className="p-5">
|
<svg className="w-5 h-5 text-secondary mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" /></svg>
|
||||||
<div className="text-xs text-slate-400 mb-4 flex items-center gap-2">
|
<div>
|
||||||
<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>
|
<div className="text-xs font-semibold text-slate-900">{l.title}</div>
|
||||||
近7日动态 · 按时间倒序排列
|
<div className="text-[10px] text-slate-500 mt-0.5">{l.org} · {l.cat}</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{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 className="flex justify-between items-start">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<svg className="w-5 h-5 text-secondary mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" /></svg>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold text-slate-900">{l.title}</div>
|
|
||||||
<div className="text-[10px] text-slate-500 mt-0.5">{l.org} · {l.cat}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-blue-100 text-blue-700">{l.level}</span>
|
|
||||||
<div className="text-[10px] text-slate-400 mt-1">截止 {l.deadline}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<span className={`text-[10px] ${l.status === '待跟进' ? 'text-rose-500' : l.status === '已转询价' ? 'text-emerald-500' : 'text-blue-500'}`}>{l.status}</span>
|
|
||||||
<span className="text-[10px] text-amber-500">{'★'.repeat(l.star)}{'☆'.repeat(5 - l.star)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-blue-100 text-blue-700">{l.level}</span>
|
||||||
|
<div className="text-[10px] text-slate-400 mt-1">截止 {l.deadline}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`text-[10px] ${l.status === '待跟进' ? 'text-rose-500' : l.status === '已转询价' ? 'text-emerald-500' : 'text-blue-500'}`}>{l.status}</span>
|
||||||
|
<span className="text-[10px] text-amber-500">{'★'.repeat(l.star)}{'☆'.repeat(5 - l.star)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* 一键发布 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 (
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!pubLoading) setShowPubModal(false) }}>
|
<Modal
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
|
open={showPubModal}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
onClose={() => setShowPubModal(false)}
|
||||||
<h3 className="text-base font-semibold">一键发布 direv</h3>
|
title="确认发布询价单"
|
||||||
<button disabled={pubLoading} onClick={() => setShowPubModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
subtitle={`即将发布至 www.direv.com.cn(共 ${selectedCount} 条线索)`}
|
||||||
<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>
|
width="w-[560px]"
|
||||||
|
loading={pubLoading}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<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 ? '发布中...' : '确认发布'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<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="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 />
|
<div className="space-y-4 text-sm">
|
||||||
品类:{lead.cat} | 数量:待确认 | 截止:{lead.deadline}<br />
|
{/* 询价信息 */}
|
||||||
联系:郑钊 15532027557
|
<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 className="text-[10px] text-slate-400">将自动匹配联系人、收货地址等信息后发布。</div>
|
</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">
|
{/* 收货信息(用户可编辑) */}
|
||||||
{pubLoading ? '发布中...' : '确认发布'}
|
<div>
|
||||||
</button>
|
<h4 className="text-xs font-semibold text-on-surface-variant mb-2">— 收货信息 <span className="text-error-red">*必填</span></h4>
|
||||||
<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="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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 发布失败 Modal */}
|
{/* 发布失败 Modal */}
|
||||||
{publishFailModal && (
|
{publishFailModal && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-start justify-center pt-20" onClick={() => setPublishFailModal(null)}>
|
<Modal
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[640px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
open={!!publishFailModal}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
onClose={() => setPublishFailModal(null)}
|
||||||
<div>
|
title="发布失败"
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
subtitle={`以下 ${publishFailModal.failed.length} 条线索未成功发布到 B2B 平台${publishFailModal.success > 0 ? `(成功 ${publishFailModal.success} 条)` : ''},请检查 MCP 对接配置后重试。`}
|
||||||
<svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
width="w-[640px]"
|
||||||
<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" />
|
footer={
|
||||||
</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') }}
|
||||||
@ -756,144 +924,139 @@ 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>
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 分配负责人 Modal */}
|
{/* 分配负责人 Modal */}
|
||||||
{batchModal === 'assign' && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
|
open={batchModal === 'assign'}
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
onClose={() => setBatchModal(null)}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
title="分配负责人"
|
||||||
<h3 className="text-base font-semibold">分配负责人</h3>
|
width="w-[480px]"
|
||||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
loading={batchLoading}
|
||||||
<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>
|
footer={
|
||||||
</button>
|
<>
|
||||||
</div>
|
<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">
|
||||||
<div className="p-5 space-y-4">
|
{batchLoading ? '处理中...' : '确认分配'}
|
||||||
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索分配给:</div>
|
</button>
|
||||||
<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">
|
<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>
|
||||||
{PERSON_OPTIONS.filter(p => p !== '全部人员' && p !== '未分配').map(p => <option key={p}>{p}</option>)}
|
</>
|
||||||
</select>
|
}
|
||||||
<div className="flex gap-3 pt-2">
|
>
|
||||||
<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">
|
<div className="space-y-4">
|
||||||
{batchLoading ? '处理中...' : '确认分配'}
|
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索分配给:</div>
|
||||||
</button>
|
<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">
|
||||||
<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>
|
{PERSON_OPTIONS.filter(p => p !== '全部人员' && p !== '未分配').map(p => <option key={p}>{p}</option>)}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* 批量转询价 Modal */}
|
{/* 批量转询价 Modal */}
|
||||||
{batchModal === 'rfq' && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
|
open={batchModal === 'rfq'}
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
|
onClose={() => setBatchModal(null)}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
title="批量转询价"
|
||||||
<h3 className="text-base font-semibold">批量转询价</h3>
|
width="w-[520px]"
|
||||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
loading={batchLoading}
|
||||||
<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>
|
footer={
|
||||||
</button>
|
<>
|
||||||
</div>
|
<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">
|
||||||
<div className="p-5 space-y-4">
|
{batchLoading ? '处理中...' : '确认转询价'}
|
||||||
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建询价单:</div>
|
</button>
|
||||||
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
|
<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>
|
||||||
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
|
</>
|
||||||
</div>
|
}
|
||||||
<div>
|
>
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
<div className="space-y-4">
|
||||||
<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">
|
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建询价单:</div>
|
||||||
<option value="郑钊">郑钊</option>
|
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
|
||||||
<option value="赵主任">赵主任</option>
|
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
|
||||||
<option value="运营管理员">运营管理员</option>
|
</div>
|
||||||
</select>
|
<div>
|
||||||
</div>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||||
<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">
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
<option value="郑钊">郑钊</option>
|
||||||
<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="可选,预填询价备注" />
|
<option value="赵主任">赵主任</option>
|
||||||
</div>
|
<option value="运营管理员">运营管理员</option>
|
||||||
<div className="flex gap-3 pt-2">
|
</select>
|
||||||
<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">
|
</div>
|
||||||
{batchLoading ? '处理中...' : '确认转询价'}
|
<div>
|
||||||
</button>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* 批量转协同 Modal */}
|
{/* 批量转协同 Modal */}
|
||||||
{batchModal === 'collab' && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
|
open={batchModal === 'collab'}
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] transform" onClick={e => e.stopPropagation()}>
|
onClose={() => setBatchModal(null)}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
title="批量转协同"
|
||||||
<h3 className="text-base font-semibold">批量转协同</h3>
|
width="w-[520px]"
|
||||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
loading={batchLoading}
|
||||||
<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>
|
footer={
|
||||||
</button>
|
<>
|
||||||
</div>
|
<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">
|
||||||
<div className="p-5 space-y-4">
|
{batchLoading ? '处理中...' : '确认转协同'}
|
||||||
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建协同任务:</div>
|
</button>
|
||||||
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
|
<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>
|
||||||
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
|
</>
|
||||||
</div>
|
}
|
||||||
<div>
|
>
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
<div className="space-y-4">
|
||||||
<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">
|
<div className="text-xs text-on-surface-variant">即将为以下 <b className="text-secondary">{selectedList.length}</b> 条线索创建协同任务:</div>
|
||||||
<option value="郑钊">郑钊</option>
|
<div className="p-3 bg-slate-50 rounded-lg text-xs max-h-32 overflow-y-auto space-y-1">
|
||||||
<option value="赵主任">赵主任</option>
|
{selectedList.map(l => <div key={l.id} className="truncate">· {l.title}</div>)}
|
||||||
<option value="运营管理员">运营管理员</option>
|
</div>
|
||||||
</select>
|
<div>
|
||||||
</div>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||||
<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">
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
<option value="郑钊">郑钊</option>
|
||||||
<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="可选备注信息" />
|
<option value="赵主任">赵主任</option>
|
||||||
</div>
|
<option value="运营管理员">运营管理员</option>
|
||||||
<div className="flex gap-3 pt-2">
|
</select>
|
||||||
<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">
|
</div>
|
||||||
{batchLoading ? '处理中...' : '确认转协同'}
|
<div>
|
||||||
</button>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* 变更状态 Modal */}
|
{/* 变更状态 Modal */}
|
||||||
{batchModal === 'status' && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!batchLoading) setBatchModal(null) }}>
|
open={batchModal === 'status'}
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
onClose={() => setBatchModal(null)}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
title="变更状态"
|
||||||
<h3 className="text-base font-semibold">变更状态</h3>
|
width="w-[480px]"
|
||||||
<button disabled={batchLoading} onClick={() => setBatchModal(null)} className="p-2 hover:bg-slate-100 rounded-full">
|
loading={batchLoading}
|
||||||
<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>
|
footer={
|
||||||
</button>
|
<>
|
||||||
</div>
|
<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">
|
||||||
<div className="p-5 space-y-4">
|
{batchLoading ? '处理中...' : '确认变更'}
|
||||||
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索状态变更为:</div>
|
</button>
|
||||||
<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">
|
<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>
|
||||||
{STATUS_OPTIONS.filter(s => s !== '全部状态' && s !== '无效线索').map(s => <option key={s}>{s}</option>)}
|
</>
|
||||||
</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">
|
<div className="space-y-4">
|
||||||
{batchLoading ? '处理中...' : '确认变更'}
|
<div className="text-xs text-on-surface-variant">将以下 <b className="text-secondary">{selectedList.length}</b> 条线索状态变更为:</div>
|
||||||
</button>
|
<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">
|
||||||
<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>
|
{STATUS_OPTIONS.filter(s => s !== '全部状态' && s !== '无效线索').map(s => <option key={s}>{s}</option>)}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -103,14 +105,13 @@ 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 [toast, setToast] = useState('')
|
const { showToast } = useToast()
|
||||||
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
|
||||||
@ -552,78 +553,68 @@ export default function LeadDetailPage({ leadId, onNavigate }: { leadId: string;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 转询价 Modal */}
|
{/* 转询价 Modal */}
|
||||||
{showRFQModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!actionLoading) setShowRFQModal(false) }}>
|
open={showRFQModal}
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
onClose={() => setShowRFQModal(false)}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
title="转为询价"
|
||||||
<h3 className="text-base font-semibold">转为询价</h3>
|
width="w-[480px]"
|
||||||
<button disabled={!!actionLoading} onClick={() => setShowRFQModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
loading={!!actionLoading}
|
||||||
<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>
|
footer={
|
||||||
</button>
|
<>
|
||||||
</div>
|
<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">
|
||||||
<div className="p-5 space-y-4">
|
{actionLoading === 'to-rfq' ? '处理中...' : '确认转询价'}
|
||||||
<div>
|
</button>
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
<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>
|
||||||
<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>
|
<div className="space-y-4">
|
||||||
</select>
|
<div>
|
||||||
</div>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||||
<div>
|
<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">
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
<option value="郑钊">郑钊</option>
|
||||||
<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="可选备注信息" />
|
<option value="赵主任">赵主任</option>
|
||||||
</div>
|
<option value="运营管理员">运营管理员</option>
|
||||||
<div className="flex gap-3 pt-2">
|
</select>
|
||||||
<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">
|
</div>
|
||||||
{actionLoading === 'to-rfq' ? '处理中...' : '确认转询价'}
|
<div>
|
||||||
</button>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* 转协同 Modal */}
|
{/* 转协同 Modal */}
|
||||||
{showCollabModal && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!actionLoading) setShowCollabModal(false) }}>
|
open={showCollabModal}
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
onClose={() => setShowCollabModal(false)}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
title="转为协同"
|
||||||
<h3 className="text-base font-semibold">转为协同</h3>
|
width="w-[480px]"
|
||||||
<button disabled={!!actionLoading} onClick={() => setShowCollabModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
loading={!!actionLoading}
|
||||||
<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>
|
footer={
|
||||||
</button>
|
<>
|
||||||
</div>
|
<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">
|
||||||
<div className="p-5 space-y-4">
|
{actionLoading === 'to-collab' ? '处理中...' : '确认转协同'}
|
||||||
<div>
|
</button>
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
<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>
|
||||||
<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>
|
<div className="space-y-4">
|
||||||
</select>
|
<div>
|
||||||
</div>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">指派负责人</span>
|
||||||
<div>
|
<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">
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
<option value="郑钊">郑钊</option>
|
||||||
<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="可选备注信息" />
|
<option value="赵主任">赵主任</option>
|
||||||
</div>
|
<option value="运营管理员">运营管理员</option>
|
||||||
<div className="flex gap-3 pt-2">
|
</select>
|
||||||
<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">
|
</div>
|
||||||
{actionLoading === 'to-collab' ? '处理中...' : '确认转协同'}
|
<div>
|
||||||
</button>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">备注</span>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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()
|
||||||
@ -24,7 +25,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f7f9fb] flex items-center justify-center">
|
<div className="min-h-screen bg-[#f7f9fb] relative 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">
|
||||||
@ -82,6 +83,9 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0">
|
||||||
|
<IcpFooter />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -346,13 +346,14 @@ 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 text-sm text-on-surface-variant">
|
<td colSpan={4} className="px-5 py-8 text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
<div className="flex gap-1">
|
||||||
<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: '0ms' }} />
|
||||||
<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: '150ms' }} />
|
||||||
</svg>
|
<div className="w-2 h-2 rounded-full bg-secondary animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
正在连接后端服务...
|
</div>
|
||||||
|
<span className="text-xs text-on-surface-variant">正在连接后端服务...</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -55,9 +59,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)
|
||||||
@ -126,11 +130,6 @@ 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)
|
||||||
@ -172,14 +171,20 @@ 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} 个商品...`)
|
||||||
await fetch(`${API}/batch-publish`, {
|
try {
|
||||||
method: 'POST',
|
const res = await fetch(`${API}/batch-publish`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||||
setSelectedIds(new Set())
|
})
|
||||||
await loadProducts()
|
const data = await res.json()
|
||||||
showToast('批量同步完成')
|
if (!res.ok) throw new Error(data.message || data.error || '请求失败')
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
await loadProducts()
|
||||||
|
showToast(`同步成功: ${data.imported || 0} 条导入 B2B${data.errors?.length ? `, 失败 ${data.errors.length} 条` : ''}`)
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(`同步失败: ${e.message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeProduct = async (id: string) => {
|
const removeProduct = async (id: string) => {
|
||||||
@ -394,7 +399,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 <div className="text-center py-20 text-on-surface-variant">加载中...</div>
|
if (loading) return <SkeletonTable rows={8} cols={8} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -451,7 +456,7 @@ export default function ProductPage() {
|
|||||||
<button disabled={selectedIds.size === 0} onClick={batchSync}
|
<button disabled={selectedIds.size === 0} onClick={batchSync}
|
||||||
className="px-3 py-1.5 text-[10px] font-medium bg-secondary text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:opacity-90">
|
className="px-3 py-1.5 text-[10px] font-medium bg-secondary text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:opacity-90">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
|
||||||
批量同步
|
批量同步
|
||||||
</button>
|
</button>
|
||||||
<button disabled={selectedIds.size === 0} onClick={batchGenerate}
|
<button disabled={selectedIds.size === 0} onClick={batchGenerate}
|
||||||
className="px-3 py-1.5 text-[10px] font-medium bg-white border border-purple-200 text-purple-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:bg-purple-50 transition-colors">
|
className="px-3 py-1.5 text-[10px] font-medium bg-white border border-purple-200 text-purple-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1 hover:bg-purple-50 transition-colors">
|
||||||
@ -589,125 +594,99 @@ export default function ProductPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import Modal */}
|
{/* Import Modal */}
|
||||||
{showImport && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => { if (!importing) setShowImport(false) }}>
|
open={showImport}
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[680px] max-h-[calc(100vh-100px)] overflow-y-auto transform" onClick={e => e.stopPropagation()}>
|
onClose={() => setShowImport(false)}
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
title="导入商品"
|
||||||
<div>
|
subtitle="支持 .xlsx, .xls, .csv 格式"
|
||||||
<h2 className="text-base font-semibold">导入商品</h2>
|
width="w-[680px]"
|
||||||
<p className="text-[10px] text-slate-500 mt-1">支持 .xlsx, .xls, .csv 格式</p>
|
loading={importing}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<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 className="space-y-4">
|
||||||
|
{/* Upload Zone */}
|
||||||
|
{!importFile && (
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
className={`border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer hover:border-secondary hover:bg-blue-50/30 ${dragOver ? 'border-secondary bg-blue-50' : 'border-border-subtle'}`}
|
||||||
|
onClick={() => fileInputRef.current?.click()}>
|
||||||
|
<input ref={fileInputRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={e => { if (e.target.files?.[0]) processFile(e.target.files[0]) }} />
|
||||||
|
<svg className="w-10 h-10 mx-auto text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
||||||
|
<p className="mt-2 text-xs text-slate-700">拖拽文件到此处,或 <a className="text-secondary hover:underline">点击选择文件</a></p>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-1">建议使用提供的商品模板进行导入</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File Info */}
|
||||||
|
{importFile && (
|
||||||
|
<div className="p-3 bg-slate-50 rounded-lg flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium">{importFile.name}</div>
|
||||||
|
<div className="text-[10px] text-slate-500">{importFile.size < 1024 ? `${importFile.size} B` : importFile.size < 1048576 ? `${(importFile.size / 1024).toFixed(1)} KB` : `${(importFile.size / 1048576).toFixed(1)} MB`}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button disabled={importing} onClick={() => setShowImport(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
<button onClick={() => { setImportFile(null); setImportPreview(null) }} className="p-1 hover:bg-slate-200 rounded">
|
||||||
<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>
|
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 space-y-4">
|
)}
|
||||||
{/* Upload Zone */}
|
|
||||||
{!importFile && (
|
|
||||||
<div
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
|
|
||||||
onDragLeave={() => setDragOver(false)}
|
|
||||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer hover:border-secondary hover:bg-blue-50/30 ${dragOver ? 'border-secondary bg-blue-50' : 'border-border-subtle'}`}
|
|
||||||
onClick={() => fileInputRef.current?.click()}>
|
|
||||||
<input ref={fileInputRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={e => { if (e.target.files?.[0]) processFile(e.target.files[0]) }} />
|
|
||||||
<svg className="w-10 h-10 mx-auto text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /></svg>
|
|
||||||
<p className="mt-2 text-xs text-slate-700">拖拽文件到此处,或 <a className="text-secondary hover:underline">点击选择文件</a></p>
|
|
||||||
<p className="text-[10px] text-slate-400 mt-1">建议使用提供的商品模板进行导入</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File Info */}
|
{/* Preview */}
|
||||||
{importFile && (
|
{importPreview && (
|
||||||
<div className="p-3 bg-slate-50 rounded-lg flex items-center justify-between">
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="text-xs font-semibold text-slate-700 mb-2">数据预览(前5条)</div>
|
||||||
<svg className="w-6 h-6 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
<div className="overflow-x-auto max-h-48 overflow-y-auto border border-border-subtle rounded-lg">
|
||||||
<div>
|
<table className="w-full text-[10px]">
|
||||||
<div className="text-xs font-medium">{importFile.name}</div>
|
<thead className="bg-slate-100 sticky top-0">
|
||||||
<div className="text-[10px] text-slate-500">{importFile.size < 1024 ? `${importFile.size} B` : importFile.size < 1048576 ? `${(importFile.size / 1024).toFixed(1)} KB` : `${(importFile.size / 1048576).toFixed(1)} MB`}</div>
|
<tr>
|
||||||
</div>
|
{['商品标题', '品牌', '规格', '单位', '价格'].map(h => (
|
||||||
</div>
|
<th key={h} className="p-2 text-left font-medium text-slate-600">{h}</th>
|
||||||
<button onClick={() => { setImportFile(null); setImportPreview(null) }} className="p-1 hover:bg-slate-200 rounded">
|
))}
|
||||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
</tr>
|
||||||
</button>
|
</thead>
|
||||||
</div>
|
<tbody>
|
||||||
)}
|
{importPreview.slice(0, 5).map((p, i) => (
|
||||||
|
<tr key={i} className="border-b border-slate-100">
|
||||||
{/* Preview */}
|
<td className="p-2 text-slate-700">{p.title}</td>
|
||||||
{importPreview && (
|
<td className="p-2 text-slate-700">{p.brand}</td>
|
||||||
<div>
|
<td className="p-2 text-slate-700">{p.spec}</td>
|
||||||
<div className="text-xs font-semibold text-slate-700 mb-2">数据预览(前5条)</div>
|
<td className="p-2 text-slate-700">{p.unit}</td>
|
||||||
<div className="overflow-x-auto max-h-48 overflow-y-auto border border-border-subtle rounded-lg">
|
<td className="p-2 text-slate-700">¥{p.price.toFixed(2)}</td>
|
||||||
<table className="w-full text-[10px]">
|
</tr>
|
||||||
<thead className="bg-slate-100 sticky top-0">
|
))}
|
||||||
<tr>
|
</tbody>
|
||||||
{['商品标题', '品牌', '规格', '单位', '价格'].map(h => (
|
</table>
|
||||||
<th key={h} className="p-2 text-left font-medium text-slate-600">{h}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{importPreview.slice(0, 5).map((p, i) => (
|
|
||||||
<tr key={i} className="border-b border-slate-100">
|
|
||||||
<td className="p-2 text-slate-700">{p.title}</td>
|
|
||||||
<td className="p-2 text-slate-700">{p.brand}</td>
|
|
||||||
<td className="p-2 text-slate-700">{p.spec}</td>
|
|
||||||
<td className="p-2 text-slate-700">{p.unit}</td>
|
|
||||||
<td className="p-2 text-slate-700">¥{p.price.toFixed(2)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-[10px] text-slate-500">共 {importPreview.length} 条数据将导入</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>
|
||||||
|
<div className="mt-2 text-[10px] text-slate-500">共 {importPreview.length} 条数据将导入</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* Template Modal */}
|
{/* Template Modal */}
|
||||||
{showTemplate && (
|
<Modal open={showTemplate} onClose={() => setShowTemplate(false)} title="商品导入模板说明" width="w-[480px]">
|
||||||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowTemplate(false)}>
|
<p className="text-xs text-slate-600 mb-3">请按以下列顺序准备Excel文件:</p>
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
|
<div className="grid grid-cols-2 gap-2 text-[10px]">
|
||||||
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
{['商品编号', 'SKU编号', '商品标题', '品牌', '一级分类', '二级分类', '规格型号', '单位', '原价', '现价', '税率'].map((c, i) => (
|
||||||
<h3 className="text-base font-semibold">商品导入模板说明</h3>
|
<div key={c} className="p-2 bg-slate-50 rounded"><span className="font-medium">{i + 1}.</span> {c}</div>
|
||||||
<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>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-[10px]">
|
|
||||||
{['商品编号', 'SKU编号', '商品标题', '品牌', '一级分类', '二级分类', '规格型号', '单位', '原价', '现价', '税率'].map((c, i) => (
|
|
||||||
<div key={c} className="p-2 bg-slate-50 rounded"><span className="font-medium">{i + 1}.</span> {c}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Modal>
|
||||||
|
|
||||||
{/* Preview Lightbox */}
|
{/* Preview Lightbox */}
|
||||||
{previewUrl && (
|
<Lightbox src={previewUrl} onClose={() => setPreviewUrl(null)} />
|
||||||
<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 && (
|
||||||
@ -727,13 +706,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,15 +11,6 @@ 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
|
||||||
@ -71,26 +62,6 @@ 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[] = [
|
||||||
@ -122,10 +93,9 @@ const TYPE_OPTIONS = [
|
|||||||
{ value: 'bid_cnncecp', label: '中核集团招采' },
|
{ value: 'bid_cnncecp', label: '中核集团招采' },
|
||||||
]
|
]
|
||||||
const CRON_PRESETS = [
|
const CRON_PRESETS = [
|
||||||
{ label: '每 4 个小时', value: '0 */4 * * *' },
|
{ label: '每 6 个小时', value: '0 0 */6 * * *' },
|
||||||
{ label: '每 6 个小时', value: '0 */6 * * *' },
|
{ label: '每天早 8 点', value: '0 0 8 * * *' },
|
||||||
{ label: '每天早 8 点', value: '0 8 * * *' },
|
{ label: '每天晚 7 点', value: '0 0 19 * * *' },
|
||||||
{ label: '每天晚 7 点', value: '0 19 * * *' },
|
|
||||||
{ label: '手动(不自动执行)', value: '' },
|
{ label: '手动(不自动执行)', value: '' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -143,17 +113,16 @@ 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 [newWord, setNewWord] = useState('')
|
const [catLoading, setCatLoading] = useState(true)
|
||||||
const [newCategory, setNewCategory] = useState(CATEGORIES[0])
|
const [showAddCat, setShowAddCat] = useState(false)
|
||||||
const [newWeight, setNewWeight] = useState(5)
|
const [addCatName, setAddCatName] = useState('')
|
||||||
|
const [addCatKeywords, setAddCatKeywords] = useState('')
|
||||||
const [showBatchModal, setShowBatchModal] = useState(false)
|
const [editCatId, setEditCatId] = useState<string | null>(null)
|
||||||
const [batchText, setBatchText] = useState('')
|
const [editCatName, setEditCatName] = useState('')
|
||||||
const [showClearModal, setShowClearModal] = useState(false)
|
const [editCatKeywords, setEditCatKeywords] = useState('')
|
||||||
|
|
||||||
const [sAmount, setSAmount] = useState(100)
|
const [sAmount, setSAmount] = useState(100)
|
||||||
const [sCoop, setSCoop] = useState(3)
|
const [sCoop, setSCoop] = useState(3)
|
||||||
@ -169,9 +138,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')
|
||||||
|
|
||||||
@ -232,53 +201,67 @@ export default function RuleCenterPage() {
|
|||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddKeyword = () => {
|
// 品类管理 - 获取列表
|
||||||
if (!newWord.trim()) return
|
const fetchCategories = () => {
|
||||||
setKeywords(prev => [
|
setCatLoading(true)
|
||||||
...prev,
|
fetch(`${API_BASE}/api/categories`)
|
||||||
{ id: nextKeywordId, word: newWord.trim(), category: newCategory, weight: newWeight, matchCount: 0, enabled: true },
|
.then(r => r.json())
|
||||||
])
|
.then(data => setCategories(data))
|
||||||
setNextKeywordId(prev => prev + 1)
|
.catch(() => {})
|
||||||
setNewWord('')
|
.finally(() => setCatLoading(false))
|
||||||
setNewWeight(5)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleKeyword = (id: number) => {
|
// 品类管理 - 添加
|
||||||
setKeywords(prev => prev.map(k => k.id === id ? { ...k, enabled: !k.enabled } : k))
|
const handleAddCategory = async () => {
|
||||||
|
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) => {
|
// 品类管理 - 编辑
|
||||||
setKeywords(prev => prev.filter(k => k.id !== id))
|
const startEditCat = (cat: any) => {
|
||||||
|
setEditCatId(cat.id)
|
||||||
|
setEditCatName(cat.name)
|
||||||
|
setEditCatKeywords((() => {
|
||||||
|
try { return (JSON.parse(cat.keywords) as string[]).join(', ') } catch { return cat.keywords || '' }
|
||||||
|
})())
|
||||||
}
|
}
|
||||||
|
|
||||||
const editKeyword = (id: number) => {
|
const handleUpdateCategory = async () => {
|
||||||
const kw = keywords.find(k => k.id === id)
|
if (!editCatId || !editCatName.trim()) return
|
||||||
if (!kw) return
|
const kwArr = editCatKeywords.split(/[,,、\s]+/).filter(Boolean)
|
||||||
setNewWord(kw.word)
|
try {
|
||||||
setNewCategory(kw.category)
|
const res = await fetch(`${API_BASE}/api/categories/${editCatId}`, {
|
||||||
setNewWeight(kw.weight)
|
method: 'PUT',
|
||||||
setKeywords(prev => prev.filter(k => k.id !== id))
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
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 lines = batchText.split('\n').map(l => l.trim()).filter(Boolean)
|
const handleDeleteCategory = async (id: string, name: string) => {
|
||||||
const newKeywords: Keyword[] = lines.map((line, i) => ({
|
if (!confirm(`确定要删除品类「${name}」吗?已有线索的该品类不受影响。`)) return
|
||||||
id: nextKeywordId + i,
|
try {
|
||||||
word: line,
|
await fetch(`${API_BASE}/api/categories/${id}`, { method: 'DELETE' })
|
||||||
category: '工程建材',
|
setCategories(prev => prev.filter(c => c.id !== id))
|
||||||
weight: 5,
|
} catch { alert('删除品类失败') }
|
||||||
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) => {
|
||||||
@ -295,6 +278,9 @@ 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({
|
||||||
@ -304,18 +290,17 @@ export default function RuleCenterPage() {
|
|||||||
sourceName: c.sourceName || '',
|
sourceName: c.sourceName || '',
|
||||||
scriptPath: c.scriptPath || '',
|
scriptPath: c.scriptPath || '',
|
||||||
schedule: c.schedule || '',
|
schedule: c.schedule || '',
|
||||||
keywords: c.keywords ? (() => { try { return JSON.parse(c.keywords).join(', ') } catch { return c.keywords } })() : '',
|
keywords: '',
|
||||||
categories: c.categories ? (() => { try { return JSON.parse(c.categories).join(', ') } catch { return c.categories } })() : '',
|
categories: c.categories ? (() => { try { const p = JSON.parse(c.categories); return Array.isArray(p) ? JSON.stringify(p) : '[]' } catch { return '[]' } })() : '[]',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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 kwArr = editForm.keywords.split(/[,,、\s]+/).filter(Boolean)
|
const catArr: string[] = editForm.categories ? JSON.parse(editForm.categories) : []
|
||||||
payload.keywords = kwArr.length > 0 ? JSON.stringify(kwArr) : ''
|
payload.categories = catArr.length > 0 ? JSON.stringify(catArr) : null
|
||||||
const catArr = editForm.categories.split(/[,,、\s]+/).filter(Boolean)
|
// 新配置不再使用 keywords 字段,但不清理旧数据(resolveKeywords 有 fallback)
|
||||||
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',
|
||||||
@ -348,8 +333,7 @@ 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 kwArr = addForm.keywords.split(/[,,、\s]+/).filter(Boolean)
|
const catArr: string[] = addForm.categories ? JSON.parse(addForm.categories) : []
|
||||||
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',
|
||||||
@ -361,7 +345,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: kwArr.length > 0 ? JSON.stringify(kwArr) : null,
|
keywords: null,
|
||||||
categories: catArr.length > 0 ? JSON.stringify(catArr) : null,
|
categories: catArr.length > 0 ? JSON.stringify(catArr) : null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@ -369,7 +353,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('创建失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -533,7 +517,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">
|
||||||
{keywords.length > 0 ? '1,248' : '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">
|
||||||
@ -588,125 +572,139 @@ export default function RuleCenterPage() {
|
|||||||
{/* Tab 2: 关键词库管理 */}
|
{/* Tab 2: 关键词库管理 */}
|
||||||
{activeTab === 'keywords' && (
|
{activeTab === 'keywords' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Add Keyword Row */}
|
{/* Header */}
|
||||||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-base font-semibold mb-4">添加关键词</h3>
|
<p className="text-sm text-on-surface-variant">
|
||||||
<div className="flex items-end gap-3">
|
管理所属品类及其关键词。采集公告时优先按关键词匹配到对应品类,匹配不到则自动 AI 推断。
|
||||||
<div className="flex-1">
|
</p>
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">关键词</span>
|
<button
|
||||||
<input
|
onClick={() => setShowAddCat(true)}
|
||||||
type="text"
|
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"
|
||||||
value={newWord}
|
>
|
||||||
onChange={e => setNewWord(e.target.value)}
|
<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>
|
||||||
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"
|
</button>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-36">
|
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">所属品类</span>
|
|
||||||
<select
|
|
||||||
value={newCategory}
|
|
||||||
onChange={e => setNewCategory(e.target.value)}
|
|
||||||
className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleAddKeyword}
|
|
||||||
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>
|
||||||
|
|
||||||
{/* Keyword Table */}
|
{/* Add Category Modal */}
|
||||||
<div className="bg-white rounded-xl border border-border-subtle shadow-sm overflow-hidden">
|
{showAddCat && (
|
||||||
<div className="p-4 border-b border-border-subtle flex justify-between items-center">
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/30" onClick={() => setShowAddCat(false)}>
|
||||||
<h3 className="text-base font-semibold">关键词列表</h3>
|
<div className="bg-white rounded-xl shadow-2xl w-[500px] transform" onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex gap-2">
|
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
|
||||||
<button
|
<h3 className="text-base font-semibold">添加所属品类</h3>
|
||||||
onClick={() => setShowBatchModal(true)}
|
<button onClick={() => setShowAddCat(false)} className="p-2 hover:bg-slate-100 rounded-full">
|
||||||
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-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>
|
||||||
<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>
|
</div>
|
||||||
批量导入
|
<div className="p-5 space-y-4">
|
||||||
</button>
|
<div>
|
||||||
<button
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">品类名称</span>
|
||||||
onClick={() => setShowClearModal(true)}
|
<input
|
||||||
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"
|
value={addCatName}
|
||||||
>
|
onChange={e => setAddCatName(e.target.value)}
|
||||||
<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>
|
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"
|
||||||
</button>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">关键词列表(逗号分隔)</span>
|
||||||
|
<textarea
|
||||||
|
value={addCatKeywords}
|
||||||
|
onChange={e => setAddCatKeywords(e.target.value)}
|
||||||
|
placeholder="彩板房, 彩钢板, 夹芯板"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-on-surface-variant mt-1">公告标题包含任一关键词即自动归入该品类</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<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>
|
||||||
|
<button onClick={() => setShowAddCat(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>
|
||||||
<table className="w-full text-left">
|
)}
|
||||||
<thead className="bg-slate-50 border-b border-border-subtle">
|
|
||||||
<tr>
|
{/* Category Table */}
|
||||||
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">关键词</th>
|
<div className="bg-white rounded-xl border border-border-subtle shadow-sm overflow-hidden">
|
||||||
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">所属品类</th>
|
{catLoading ? (
|
||||||
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right">权重</th>
|
<div className="p-8 text-center text-sm text-on-surface-variant">加载中...</div>
|
||||||
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right">匹配次数</th>
|
) : categories.length === 0 ? (
|
||||||
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">启用状态</th>
|
<div className="p-8 text-center text-sm text-on-surface-variant">暂无品类,请点击右上角「添加品类」</div>
|
||||||
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">操作</th>
|
) : (
|
||||||
</tr>
|
<table className="w-full text-left">
|
||||||
</thead>
|
<thead className="bg-slate-50 border-b border-border-subtle">
|
||||||
<tbody className="divide-y divide-border-subtle">
|
|
||||||
{keywords.length === 0 ? (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="p-8 text-center text-sm text-on-surface-variant">暂无关键词,请添加</td>
|
<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 w-40 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
</thead>
|
||||||
keywords.map(kw => (
|
<tbody className="divide-y divide-border-subtle">
|
||||||
<tr key={kw.id} className="hover:bg-slate-50 transition-colors">
|
{categories.map(cat => {
|
||||||
<td className="p-4 text-sm font-medium">{kw.word}</td>
|
const kwList: string[] = (() => { try { return JSON.parse(cat.keywords) } catch { return [] } })()
|
||||||
<td className="p-4 text-sm text-on-surface-variant">
|
return editCatId === cat.id ? (
|
||||||
<span className="px-2 py-0.5 bg-slate-100 text-on-surface-variant text-[10px] font-medium rounded">{kw.category}</span>
|
<tr key={cat.id} className="bg-blue-50/30">
|
||||||
</td>
|
<td className="p-3">
|
||||||
<td className="p-4 text-sm text-right font-semibold">{kw.weight}</td>
|
<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 className="p-4 text-sm text-right text-on-surface-variant">{kw.matchCount}</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-3">
|
||||||
<button
|
<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" />
|
||||||
onClick={() => toggleKeyword(kw.id)}
|
</td>
|
||||||
className={`relative w-9 h-5 rounded-full transition-colors ${kw.enabled ? 'bg-secondary' : 'bg-slate-300'}`}
|
<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'}`}>
|
||||||
<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'}`} />
|
{cat.isAIGenerated ? 'AI' : '手动'}
|
||||||
</button>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => editKeyword(kw.id)} className="text-xs text-blue-600 hover:underline">编辑</button>
|
<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>
|
||||||
<span className="text-slate-300">|</span>
|
<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>
|
||||||
<button onClick={() => deleteKeyword(kw.id)} className="text-xs text-rose-500 hover:underline">删除</button>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
) : (
|
||||||
))
|
<tr key={cat.id} className="hover:bg-slate-50 transition-colors">
|
||||||
)}
|
<td className="p-4 text-sm font-semibold">{cat.name}</td>
|
||||||
</tbody>
|
<td className="p-4">
|
||||||
</table>
|
<div className="flex flex-wrap gap-1 max-w-[500px]">
|
||||||
<div className="bg-slate-50 px-4 py-2 border-t border-border-subtle flex justify-between items-center">
|
{kwList.length === 0 ? (
|
||||||
<span className="text-xs text-on-surface-variant">共 {keywords.length} 个关键词</span>
|
<span className="text-xs text-slate-400">无关键词</span>
|
||||||
{keywords.length > 0 && (
|
) : (
|
||||||
|
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 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'}`}>
|
||||||
|
{cat.isAIGenerated ? 'AI' : '手动'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => startEditCat(cat)} className="text-xs text-blue-600 hover:underline">编辑</button>
|
||||||
|
<span className="text-slate-300">|</span>
|
||||||
|
<button onClick={() => handleDeleteCategory(cat.id, cat.name)} className="text-xs text-rose-500 hover:underline">删除</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{!catLoading && categories.length > 0 && (
|
||||||
|
<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-[10px] text-on-surface-variant">
|
<span className="text-[10px] text-on-surface-variant">
|
||||||
启用 {keywords.filter(k => k.enabled).length} 个 · 停用 {keywords.filter(k => !k.enabled).length} 个
|
手动 {categories.filter(c => !c.isAIGenerated).length} 个 · AI 创建 {categories.filter(c => c.isAIGenerated).length} 个
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -739,7 +737,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>
|
||||||
@ -762,7 +760,28 @@ export default function RuleCenterPage() {
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<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" />
|
<div className="max-h-[150px] overflow-y-auto space-y-1 text-xs">
|
||||||
|
{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)}`}>
|
||||||
@ -785,23 +804,34 @@ 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.keywords ? (
|
{c.categories ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(JSON.parse(c.keywords) as string[]).slice(0, 3).map((kw, i) => (
|
{(JSON.parse(c.categories) as string[]).slice(0, 3).map((cat, i) => (
|
||||||
<span key={i} className="px-1.5 py-0.5 bg-blue-50 text-blue-600 text-[10px] rounded">{kw}</span>
|
<span key={i} className="px-1.5 py-0.5 bg-purple-50 text-purple-600 text-[10px] rounded">{cat}</span>
|
||||||
))}
|
))}
|
||||||
{(JSON.parse(c.keywords) as string[]).length > 3 && (
|
{(JSON.parse(c.categories) as string[]).length > 3 && (
|
||||||
<span className="text-[10px] text-slate-400">+{JSON.parse(c.keywords).length - 3}</span>
|
<span className="text-[10px] text-slate-400">+{JSON.parse(c.categories).length - 3}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : c.keywords ? (
|
||||||
|
<div className="flex flex-wrap gap-1 items-center">
|
||||||
|
{(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 className="text-[10px] text-slate-400">(旧)</span>
|
||||||
|
</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-2 py-0.5 text-[10px] font-medium rounded-full border ${getStatusStyle(c.status)}`}
|
className={`px-3 py-1 text-[11px] font-semibold rounded-md transition-all cursor-pointer ${
|
||||||
|
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">
|
||||||
@ -1046,34 +1076,6 @@ 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)}>
|
||||||
@ -1109,12 +1111,34 @@ 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-1">过滤关键词(逗号分隔,留空表示抓取全部)</span>
|
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-2">关联品类(选中的品类关键词用于过滤公告)</span>
|
||||||
<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" />
|
<div className="max-h-[200px] overflow-y-auto border border-border-subtle rounded-lg p-3 space-y-2">
|
||||||
</div>
|
{categories.length === 0 ? (
|
||||||
<div className="col-span-2">
|
<p className="text-xs text-on-surface-variant">暂无品类,请先在「关键词库管理」中添加品类</p>
|
||||||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">所属品类(逗号分隔,留空表示全部品类)</span>
|
) : categories.map(cat => {
|
||||||
<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" />
|
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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
@ -1174,29 +1198,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,13 +11,6 @@ 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: '使用统计' },
|
||||||
@ -25,6 +18,104 @@ 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,
|
||||||
@ -37,7 +128,6 @@ 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,
|
||||||
})
|
})
|
||||||
@ -45,10 +135,6 @@ 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 (
|
||||||
@ -66,186 +152,84 @@ 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">
|
||||||
<button
|
<NavItem
|
||||||
|
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="业务场景" />
|
||||||
|
|
||||||
<div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
|
<NavItem
|
||||||
业务场景
|
active={current === 'inquiry'}
|
||||||
</div>
|
icon="📋"
|
||||||
|
label="询价发布助手"
|
||||||
<div>
|
onClick={() => onNavigate('inquiry')}
|
||||||
<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')}
|
|
||||||
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) => (
|
||||||
<button
|
<NavItem
|
||||||
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="系统管理" />
|
||||||
|
|
||||||
<div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
|
<NavGroup
|
||||||
系统管理
|
icon="⚙️"
|
||||||
</div>
|
label="系统管理"
|
||||||
|
active={['report-center', 'rule-center', 'system-monitor'].includes(current)}
|
||||||
<div>
|
expanded={expandedGroups.system}
|
||||||
<button
|
onToggle={() => toggleGroup('system')}
|
||||||
onClick={() => toggleGroup('system')}
|
>
|
||||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
|
<SubNavItem
|
||||||
['report-center', 'rule-center', 'system-monitor', 'integrations'].includes(current)
|
active={current === 'report-center'}
|
||||||
? 'bg-secondary text-white font-semibold'
|
label="报告中心"
|
||||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
onClick={() => onNavigate('report-center')}
|
||||||
}`}
|
/>
|
||||||
>
|
<SubNavItem
|
||||||
<div className="flex items-center gap-3">
|
active={current === 'rule-center'}
|
||||||
<span>⚙️</span>
|
label="规则中心"
|
||||||
<span>系统管理</span>
|
onClick={() => onNavigate('rule-center')}
|
||||||
</div>
|
/>
|
||||||
<span className={`transition-transform text-xs ${expandedGroups.system ? 'rotate-90' : ''}`}>▶</span>
|
<SubNavItem
|
||||||
</button>
|
active={current === 'system-monitor'}
|
||||||
{expandedGroups.system && (
|
label="系统监控"
|
||||||
<div className="ml-2 space-y-1 mt-1">
|
onClick={() => onNavigate('system-monitor')}
|
||||||
<button
|
/>
|
||||||
onClick={() => onNavigate('report-center')}
|
</NavGroup>
|
||||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
|
|
||||||
current === 'report-center'
|
|
||||||
? '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('rule-center')}
|
|
||||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
|
|
||||||
current === 'rule-center'
|
|
||||||
? '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('system-monitor')}
|
|
||||||
className={`w-full flex items-center gap-3 px-4 py-2 rounded-lg text-sm transition-colors ${
|
|
||||||
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" />
|
||||||
<div className="px-4 py-2 text-xs font-semibold text-outline uppercase tracking-wider">
|
<GroupLabel label="运营管理" />
|
||||||
运营管理
|
<NavGroup
|
||||||
</div>
|
icon="🔧"
|
||||||
<div>
|
label="运营后台"
|
||||||
<button
|
active={isInAdmin}
|
||||||
onClick={() => toggleGroup('admin')}
|
expanded={expandedGroups.admin}
|
||||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm transition-colors ${
|
onToggle={() => toggleGroup('admin')}
|
||||||
isInAdmin
|
>
|
||||||
? 'bg-secondary text-white font-semibold'
|
{adminPages.map((p) => (
|
||||||
: 'text-on-surface-variant hover:bg-surface-container-low'
|
<SubNavItem
|
||||||
}`}
|
key={p.key}
|
||||||
>
|
active={current === p.key}
|
||||||
<div className="flex items-center gap-3">
|
label={p.label}
|
||||||
<span>🔧</span>
|
onClick={() => onNavigate(p.key)}
|
||||||
<span>运营后台</span>
|
/>
|
||||||
</div>
|
))}
|
||||||
<span className={`transition-transform text-xs ${expandedGroups.admin ? 'rotate-90' : ''}`}>▶</span>
|
</NavGroup>
|
||||||
</button>
|
|
||||||
{expandedGroups.admin && (
|
|
||||||
<div className="ml-2 space-y-1 mt-1">
|
|
||||||
{adminPages.map((p) => (
|
|
||||||
<button
|
|
||||||
key={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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
34
apps/web/src/components/ui/Lightbox.tsx
Normal file
34
apps/web/src/components/ui/Lightbox.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
apps/web/src/components/ui/Modal.tsx
Normal file
79
apps/web/src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
apps/web/src/components/ui/Skeleton.tsx
Normal file
68
apps/web/src/components/ui/Skeleton.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
apps/web/src/components/ui/Toast.tsx
Normal file
74
apps/web/src/components/ui/Toast.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ 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
@ -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://zoupeng.cloud
|
curl -s -o /dev/null -w 'Web: %{http_code}\n' https://direv.cloud
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -105,6 +105,8 @@ curl -s -o /dev/null -w 'Web: %{http_code}\n' https://zoupeng.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
217
package-lock.json
generated
@ -32,6 +32,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@ -1209,6 +1210,190 @@
|
|||||||
"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,
|
||||||
@ -6839,6 +7024,38 @@
|
|||||||
"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"
|
||||||
|
|||||||
98
scripts/CATEGORY_CLEANUP.md
Normal file
98
scripts/CATEGORY_CLEANUP.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# 品类清理建议
|
||||||
|
|
||||||
|
**生成时间**: 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
|
||||||
47
scripts/assign_remaining.js
Normal file
47
scripts/assign_remaining.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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())
|
||||||
113
scripts/backfill_category.js
Normal file
113
scripts/backfill_category.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
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())
|
||||||
79
scripts/clean_category.js
Normal file
79
scripts/clean_category.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
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())
|
||||||
36
scripts/cleanup_garbage_categories.sql
Normal file
36
scripts/cleanup_garbage_categories.sql
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
-- 清理垃圾关键词品类脚本
|
||||||
|
-- 使用: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;
|
||||||
63
scripts/deploy.sh
Executable file
63
scripts/deploy.sh
Executable file
@ -0,0 +1,63 @@
|
|||||||
|
#!/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 "=========================================="
|
||||||
86
scripts/expand_keywords.js
Normal file
86
scripts/expand_keywords.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
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())
|
||||||
132
scripts/extract_address_from_pdf.py
Normal file
132
scripts/extract_address_from_pdf.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
#!/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))
|
||||||
33
scripts/fix_corrupted_titles.sql
Normal file
33
scripts/fix_corrupted_titles.sql
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
-- 修复乱码标题:将 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) || '%';
|
||||||
153
scripts/reclassify_leads.js
Normal file
153
scripts/reclassify_leads.js
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* 重新分类脚本:对 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)
|
||||||
107
scripts/refetch_corrupted.js
Normal file
107
scripts/refetch_corrupted.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
#!/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)
|
||||||
69
scripts/restore_categories.js
Normal file
69
scripts/restore_categories.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 恢复 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())
|
||||||
80
scripts/sanitize_smoke.js
Normal file
80
scripts/sanitize_smoke.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// 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)
|
||||||
@ -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_date": "",
|
"publish_time": "",
|
||||||
"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_date"] = text
|
notice["publish_time"] = 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,6 +173,39 @@ 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 = [
|
||||||
@ -195,7 +228,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_date']}",
|
f"- **发布日期**: {n['publish_time']}",
|
||||||
f"- **公告类型**: {n['type']}",
|
f"- **公告类型**: {n['type']}",
|
||||||
f"- **状态**: {status_display}",
|
f"- **状态**: {status_display}",
|
||||||
f"- **文件下载截止**: {deadline_display}",
|
f"- **文件下载截止**: {deadline_display}",
|
||||||
@ -225,15 +258,21 @@ 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=0, help="最大翻页数(0=不限制)")
|
parser.add_argument("--max-pages", type=int, default=10, help="最大翻页数(0=不限制,默认10页≈160条)")
|
||||||
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 - timedelta(days=7)
|
date_from = today
|
||||||
|
|
||||||
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()
|
||||||
@ -247,12 +286,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.strftime("%Y-%m-%d"))
|
output_dir = os.path.join(OUTPUT_BASE, today_str)
|
||||||
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 = load_state(state_file)
|
state = accumulated_state
|
||||||
|
|
||||||
all_notices = []
|
all_notices = []
|
||||||
new_count = 0
|
new_count = 0
|
||||||
@ -306,7 +345,7 @@ def run():
|
|||||||
type_total += 1
|
type_total += 1
|
||||||
|
|
||||||
# 日期过滤
|
# 日期过滤
|
||||||
if not match_date_range(notice["publish_date"], date_from, date_to):
|
if not match_date_range(notice.get("publish_time", ""), date_from, date_to):
|
||||||
type_filtered += 1
|
type_filtered += 1
|
||||||
filtered_count += 1
|
filtered_count += 1
|
||||||
continue
|
continue
|
||||||
@ -329,7 +368,7 @@ def run():
|
|||||||
state[notice_id] = {
|
state[notice_id] = {
|
||||||
"title": notice["title"],
|
"title": notice["title"],
|
||||||
"type": notice["type"],
|
"type": notice["type"],
|
||||||
"publish_date": notice["publish_date"],
|
"publish_time": notice["publish_time"],
|
||||||
"deadline": notice["deadline"],
|
"deadline": notice["deadline"],
|
||||||
"status": notice["status"],
|
"status": notice["status"],
|
||||||
"detail_url": notice["detail_url"],
|
"detail_url": notice["detail_url"],
|
||||||
@ -352,7 +391,8 @@ 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)
|
||||||
|
|
||||||
# 生成清单
|
# 生成清单
|
||||||
|
|||||||
@ -25,16 +25,26 @@ const http = require('http');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
// ========== 配置区 ==========
|
// ========== 配置区 ==========
|
||||||
const KEYWORD = '水电十一局';
|
|
||||||
// 公告类型:可按需增减。支持的类型值见 API 返回的 announcementType 字段
|
// 支持命令行传入 --keyword(例如: node fetch-v1.2.js --keyword 电缆)
|
||||||
const TYPES = ['招采公告', '资格预审公告', '采购预告', '中标结果公示'];
|
const args = process.argv.slice(2)
|
||||||
// 可选:留空则从上次抓取日期继续;有值则从指定日期开始
|
const keywordIdx = args.indexOf('--keyword')
|
||||||
const START_DATE = '';
|
const KEYWORD = (keywordIdx >= 0 && args[keywordIdx + 1]) ? args[keywordIdx + 1] : ''
|
||||||
|
|
||||||
|
// 公告类型:与 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;
|
||||||
@ -97,9 +107,10 @@ function request(url, options = {}, retries = RETRY_TIMES) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const req = protocol.request(reqOptions, (res) => {
|
const req = protocol.request(reqOptions, (res) => {
|
||||||
let data = '';
|
const chunks = [];
|
||||||
res.on('data', chunk => data += chunk);
|
res.on('data', chunk => chunks.push(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')) {
|
||||||
@ -220,6 +231,54 @@ 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) {
|
||||||
@ -293,12 +352,23 @@ async function main() {
|
|||||||
|
|
||||||
log(`========== 开始抓取: ${KEYWORD} ==========`);
|
log(`========== 开始抓取: ${KEYWORD} ==========`);
|
||||||
|
|
||||||
// 确定起始日期(差量:上次运行日期 或 配置的 START_DATE)
|
// 确定起始日期:START_DATE 为空时退缩到上次运行的前 1 天(重叠 24 小时,覆盖 cron 间隔内漏抓的公告)
|
||||||
const sinceDate = START_DATE || (state.lastRun ? state.lastRun.split('T')[0] : null) || END_DATE;
|
// state.downloaded 通过 systemId 去重,重叠不会造成重复入库
|
||||||
|
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(`差量模式: 自 ${state.lastRun.substring(0, 10)} 以来新增`);
|
log(`增量模式(含 1 天回溯): ${sinceDate} ~ ${END_DATE}`);
|
||||||
|
} else if (sinceDate !== END_DATE) {
|
||||||
|
log(`跨天补抓: ${sinceDate} ~ ${END_DATE}`);
|
||||||
} else {
|
} else {
|
||||||
log(`首次运行: ${sinceDate} ~ ${END_DATE}`);
|
log(`首次运行: ${sinceDate} ~ ${END_DATE}`);
|
||||||
}
|
}
|
||||||
@ -332,9 +402,11 @@ async function main() {
|
|||||||
|
|
||||||
log(`共获取 ${stats.total} 条公告(分 ${page} 页)`);
|
log(`共获取 ${stats.total} 条公告(分 ${page} 页)`);
|
||||||
|
|
||||||
// 过滤目标公告类型(由 TYPES 配置定义)
|
// 过滤目标公告类型(由 TYPES 配置定义),且仅取货物类
|
||||||
const caigou = allAnnouncements.filter(r => TYPES.includes(r.announcementType));
|
const caigou = allAnnouncements.filter(r =>
|
||||||
log(`其中 ${TYPES.join('、')}: ${caigou.length} 条`);
|
TYPES.includes(r.announcementType) && r.titleTypeName === '货物类'
|
||||||
|
);
|
||||||
|
log(`其中 ${TYPES.join('、')}(货物类): ${caigou.length} 条`);
|
||||||
|
|
||||||
// 生成公告清单(所有本次抓到的招采公告,无论是否已下载)
|
// 生成公告清单(所有本次抓到的招采公告,无论是否已下载)
|
||||||
const listMd = generateAnnouncementList(caigou, sinceDate, END_DATE);
|
const listMd = generateAnnouncementList(caigou, sinceDate, END_DATE);
|
||||||
@ -363,7 +435,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 || '', time: new Date().toISOString() };
|
state.downloaded[key] = { id: r.id, title: r.title, skipped: true, deadline: r.submissionDeadline || '', publishTime: r.publishTime || '', time: new Date().toISOString() };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -376,15 +448,20 @@ 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 || '', path: `pdf/${fname}`, size, time: new Date().toISOString() };
|
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 };
|
||||||
stats.success++;
|
stats.success++;
|
||||||
log(` ✅ ${fname} (${size} bytes)`);
|
log(` ✅ ${fname} (${size} bytes) region=${region || '-'} addr=${regionDetail || '-'}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stats.failed++;
|
stats.failed++;
|
||||||
state.downloaded[key] = { id: r.id, title: r.title, deadline: r.submissionDeadline || '', failed: true, error: err.message, time: new Date().toISOString() };
|
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 };
|
||||||
log(` ❌ ${fname}: ${err.message}`, 'ERROR');
|
log(` ❌ ${fname}: ${err.message}`, 'ERROR');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,23 +1,35 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
新矿集团供应链平台 公告抓取工具
|
新矿集团供应链平台 公告抓取工具
|
||||||
数据源:https://xkgroup.kdcloud.com/isv/q5m9/dist/srmportal.html
|
数据源:http://120.70.100.128:8096/(新站,2026-10 切换)
|
||||||
API:getPortalNoticeData (REST, 无需登录即可访问 guest 数据)
|
- 纯服务端渲染(layui),无 JSON API,抓 HTML 列表页解析
|
||||||
|
- 列表页:/front/notice/cgxx?category=bid|procure|auction(GET 表单分页,pageNo 从 0 起,每页 10 条)
|
||||||
|
- 详情页:/front/index/v_notice_view?id=<32hex>(含正文 + 附件 PDF 直链)
|
||||||
|
|
||||||
抓取类型:
|
板块类型标签全集:
|
||||||
C - 寻源公告(核心采购公告)
|
bid 招标公告 / 变更&澄清公告 / 资格预审公告 / 中标候选人公示 / 中标公告 / 废标公告
|
||||||
D - 流标公告
|
procure 采购公告 / 变更公告 / 结果公示 / 其他公告
|
||||||
B - 结果公示
|
auction 竞价公告 / 竞价流拍
|
||||||
|
|
||||||
|
默认抓取"商机类 + 流标类"(对应旧站 C 寻源 + D 流标语义):
|
||||||
|
商机类 = 招标公告/采购公告/竞价公告/资格预审公告
|
||||||
|
流标类 = 废标公告/竞价流拍
|
||||||
|
--all-types 额外抓结果/其他类(中标候选人公示/中标公告/变更&澄清/结果公示/其他公告)
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python3 fetch-xkgroup.py # 差量更新(默认抓取昨天的寻源公告)
|
python3 fetch-xkgroup.py # 差量更新(默认回溯到上次运行/7 天内)
|
||||||
python3 fetch-xkgroup.py --all-types # 抓取所有公告类型
|
python3 fetch-xkgroup.py --all-types # 抓取所有公告类型
|
||||||
python3 fetch-xkgroup.py --from 2026-05-01 # 指定起始日期
|
python3 fetch-xkgroup.py --from 2026-10-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
|
||||||
@ -31,93 +43,126 @@ except ImportError:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# ============ 配置区 ============
|
# ============ 配置区 ============
|
||||||
BASE_URL = "https://xkgroup.kdcloud.com/kapi/app/srm/srmwebapi"
|
BASE_URL = "http://120.70.100.128:8096"
|
||||||
ACCOUNT_ID = "2066845847806639104"
|
LIST_URL = f"{BASE_URL}/front/notice/cgxx"
|
||||||
PAGE_SIZE = 50
|
PAGE_SIZE = 10 # 新站固定每页 10 条
|
||||||
|
MAX_PAGES_DEFAULT = 5 # 每板块默认最多翻 5 页(日增量远小于此,历史回填可加大)
|
||||||
|
|
||||||
# 公告类型映射
|
# 板块与类型标签
|
||||||
NOTICE_TYPES = {
|
CATEGORIES = ["bid", "procure", "auction"]
|
||||||
"C": "寻源公告",
|
|
||||||
"D": "流标公告",
|
# 商机类 + 流标类(默认抓取,对应旧版 C+D)
|
||||||
"B": "结果公示",
|
BUSINESS_TAGS = {"招标公告", "采购公告", "竞价公告", "资格预审公告"}
|
||||||
}
|
FLOWN_TAGS = {"废标公告", "竞价流拍"}
|
||||||
DEFAULT_TYPES = ["C"] # 默认只抓取寻源公告
|
# 结果/其他类(--all-types 才抓)
|
||||||
|
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": "application/json, text/plain, */*",
|
"Accept": "text/html,application/xhtml+xml,*/*",
|
||||||
"Referer": "https://xkgroup.kdcloud.com/isv/q5m9/dist/srmportal.html",
|
"Referer": f"{BASE_URL}/",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============ 核心函数 ============
|
# ============ 抓取层(新站 HTML 解析) ============
|
||||||
|
|
||||||
|
|
||||||
def fetch_notices(notice_type, page=1, size=PAGE_SIZE):
|
def fetch_list_page(category, page_no, timeout=30):
|
||||||
"""调用 API 获取公告列表"""
|
"""抓取列表页 HTML(GET 表单,pageNo 从 0 起)"""
|
||||||
params = {
|
resp = requests.get(
|
||||||
"page": page,
|
LIST_URL,
|
||||||
"size": size,
|
params={"category": category, "pageNo": page_no},
|
||||||
"type": notice_type,
|
headers=HEADERS,
|
||||||
"apiname": "getPortalNoticeData",
|
timeout=timeout,
|
||||||
"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.json()
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
def fetch_all_notices(notice_type):
|
def parse_list_html(html_text):
|
||||||
"""翻页获取指定类型的所有公告"""
|
"""解析列表页 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 = []
|
||||||
page = 1
|
for page_no in range(max_pages):
|
||||||
|
html_text = fetch_list_page(category, page_no)
|
||||||
while True:
|
items = parse_list_html(html_text)
|
||||||
data = fetch_notices(notice_type, page=page)
|
if not items:
|
||||||
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
|
# 倒序排列:本页最旧一条都早于起始日期,无需继续翻页
|
||||||
print(f" 第 {page}/{total_page} 页: {len(items)} 条")
|
dates = [it["publish_date"] for it in items if it["publish_date"]]
|
||||||
|
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 normalize_notice(item, notice_type_label):
|
def fetch_detail(notice_id, timeout=30):
|
||||||
"""统一公告数据格式"""
|
"""抓取详情页,返回 (html_text, body_text, attach_list)
|
||||||
notice_id = item.get("noticeId", "")
|
|
||||||
pub_time = item.get("publishtime", "")
|
|
||||||
|
|
||||||
# 解析状态
|
body_text 为去标签后的正文纯文本(供金额/截止/地区提取),
|
||||||
status = item.get("status", "")
|
attach_list 为详情页附件直链列表(PDF/Word/Excel)。
|
||||||
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 {
|
|
||||||
"id": notice_id,
|
def download_attachment(url, save_path, timeout=60):
|
||||||
"title": item.get("noticename", ""),
|
"""下载附件文件(PDF/Word/Excel 直链)"""
|
||||||
"org": item.get("orgname", ""),
|
resp = requests.get(url, headers={"User-Agent": HEADERS["User-Agent"]}, timeout=timeout, stream=True)
|
||||||
"org_number": item.get("orgnumber", ""),
|
resp.raise_for_status()
|
||||||
"type": notice_type_label,
|
with open(save_path, "wb") as f:
|
||||||
"type_code": item.get("type", ""),
|
for chunk in resp.iter_content(chunk_size=65536):
|
||||||
"status": norm_status,
|
f.write(chunk)
|
||||||
"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):
|
||||||
@ -150,27 +195,63 @@ 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"- **发布组织**: {n['org']}",
|
f"## {i}. {n['title']}",
|
||||||
f"- **类型**: {n['type']}",
|
f"- **发布单位**: {n['org']}",
|
||||||
f"- **状态**: {n['status']}",
|
f"- **类型**: {n['type']}",
|
||||||
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("~", "-")
|
||||||
@ -184,143 +265,127 @@ def generate_markdown(notices, output_dir, date_range):
|
|||||||
return filepath
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def extract_deadline_from_text(text):
|
||||||
parser = argparse.ArgumentParser(description="新矿集团供应链平台 公告抓取")
|
"""从公告正文中提取截止日期(采购文件获取截止时间)"""
|
||||||
parser.add_argument("--from", dest="from_date", help="起始日期 YYYY-MM-DD")
|
if not text:
|
||||||
parser.add_argument("--to", dest="to_date", help="截止日期 YYYY-MM-DD")
|
return ""
|
||||||
parser.add_argument("--all-types", action="store_true", help="抓取所有公告类型")
|
# 模式1: "起至2026年6月22日19:00(北京时间)止" → 取截止日期
|
||||||
parser.add_argument("--force", 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("--keyword", default="", help="关键词过滤")
|
if m:
|
||||||
args = parser.parse_args()
|
return f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}"
|
||||||
|
# 模式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:
|
# 全国省份/直辖市/自治区
|
||||||
date_from = datetime.strptime(args.from_date, "%Y-%m-%d").date()
|
PROVINCES = [
|
||||||
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
|
|
||||||
|
|
||||||
date_range = f"{date_from}~{date_to}"
|
def extract_province_best(text):
|
||||||
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):
|
||||||
output_dir = os.path.join(OUTPUT_BASE, today.strftime("%Y-%m-%d"))
|
"""从文本中提取城市名(XX市)"""
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
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 ''
|
||||||
|
|
||||||
# 子目录: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")
|
"""从公告正文或公司名中提取地区(省份)"""
|
||||||
state = load_state(state_file)
|
if not text and not org:
|
||||||
|
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
|
|
||||||
|
|
||||||
print(f"===== 新矿集团供应链平台 公告抓取 =====")
|
def extract_region_detail_from_text(text):
|
||||||
print(f"日期范围: {date_from} ~ {date_to} ({days_span}天)")
|
"""从公告正文中提取详细收货/交货地址"""
|
||||||
print(f"公告类型: {', '.join([NOTICE_TYPES.get(t, t) for t in notice_types])}")
|
if not text:
|
||||||
print()
|
return ""
|
||||||
|
for pat in [
|
||||||
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|$)",
|
||||||
|
r"项目地点[::]\s*([\u4e00-\u9fff\d\-—\s]{6,60}?)(?:\n|$)",
|
||||||
items = fetch_all_notices(ntype)
|
]:
|
||||||
print(f" ✅ API 返回 {len(items)} 条")
|
m = re.search(pat, text)
|
||||||
|
if m:
|
||||||
type_new = 0
|
addr = m.group(1).strip()
|
||||||
type_skip = 0
|
if len(addr) >= 6:
|
||||||
for item in items:
|
return addr
|
||||||
notice = normalize_notice(item, label)
|
return ""
|
||||||
|
|
||||||
# 日期过滤
|
|
||||||
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¬iceId={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):
|
||||||
@ -353,179 +418,219 @@ def extract_amount_from_text(text):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def extract_amounts_for_existing(state, output_dir):
|
def process_details(notices, output_dir, html_dir, pdf_dir, state, state_file):
|
||||||
"""为已抓取但缺少金额的公告,访问详情页提取金额"""
|
"""对新公告做详情页增强:存 HTML 源码 + 提取金额/截止/地区 + 下载附件 PDF
|
||||||
try:
|
|
||||||
import asyncio
|
新站详情页是纯 SSR HTML,requests 直接可取(不再依赖 Playwright)。
|
||||||
from playwright.async_api import async_playwright
|
单条失败仅告警,不阻断。
|
||||||
except ImportError:
|
"""
|
||||||
print(" ⚠️ Playwright 未安装,跳过金额提取")
|
if not notices:
|
||||||
return
|
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:
|
||||||
|
html_text, body_text, attach_list = fetch_detail(notice_id)
|
||||||
|
|
||||||
# 筛选缺少金额的记录
|
pub_date = notice.get("publish_time", "")[:10]
|
||||||
pending = []
|
safe_title = re.sub(r"[\\/:*?\"<>|]", "_", notice["title"][:40])
|
||||||
for notice_id, info in state.items():
|
base_name = f"{pub_date}_{notice_id}_{safe_title}"
|
||||||
if notice_id == "lastRun":
|
html_name = f"{base_name}.html"
|
||||||
continue
|
with open(os.path.join(html_dir, html_name), "w", encoding="utf-8") as f:
|
||||||
if info.get("amount"):
|
f.write(html_text)
|
||||||
continue
|
state[notice_id]["html"] = f"html/{html_name}"
|
||||||
pending.append(notice_id)
|
|
||||||
|
|
||||||
if not pending:
|
# 字段提取
|
||||||
print(" ✅ 所有记录已有金额信息")
|
amount = extract_amount_from_text(body_text)
|
||||||
return
|
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"\n[金额提取] {len(pending)} 条记录缺少金额,正在从详情页提取...")
|
region_detail = extract_region_detail_from_text(body_text)
|
||||||
|
if amount:
|
||||||
async def _run():
|
state[notice_id]["amount"] = amount
|
||||||
async with async_playwright() as p:
|
if deadline:
|
||||||
browser = await p.chromium.launch(headless=True)
|
state[notice_id]["deadline"] = deadline
|
||||||
success = 0
|
if region:
|
||||||
|
state[notice_id]["region"] = region
|
||||||
for i, notice_id in enumerate(pending):
|
if region_detail:
|
||||||
info = state[notice_id]
|
state[notice_id]["region_detail"] = region_detail
|
||||||
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)
|
|
||||||
|
|
||||||
|
# 附件下载(取第一个 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:
|
||||||
page = await browser.new_page()
|
download_attachment(attach_list[0], os.path.join(pdf_dir, pdf_name))
|
||||||
page.set_default_timeout(20000)
|
state[notice_id]["pdf"] = f"pdf/{pdf_name}"
|
||||||
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:
|
except Exception as e:
|
||||||
print(f"❌ {e}")
|
print(f"⚠️ 附件下载失败({e})", end=" ")
|
||||||
|
|
||||||
await browser.close()
|
print(f"✅ amount={amount or '-'} deadline={deadline or '-'} region={region or '-'}")
|
||||||
return success
|
success += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {e}")
|
||||||
|
|
||||||
import asyncio
|
# 边处理边落盘,中断不丢已处理的记录
|
||||||
try:
|
with open(state_file, "w", encoding="utf-8") as f:
|
||||||
success = asyncio.run(_run())
|
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||||
except RuntimeError:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
success = loop.run_until_complete(_run())
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
save_state(os.path.join(output_dir, "xkgroup_downloaded.json"), state)
|
print(f" 📦 详情增强完成: {success}/{len(notices)} 条")
|
||||||
print(f" 💰 金额提取完成: {success} 条")
|
|
||||||
|
|
||||||
|
|
||||||
def download_pdfs(notices, output_dir, html_dir, pdf_dir, state=None, state_file=None, force=False):
|
def run():
|
||||||
"""将新矿集团公告详情页转存为 PDF"""
|
parser = argparse.ArgumentParser(description="新矿集团供应链平台 公告抓取(新站 SSR 版)")
|
||||||
try:
|
parser.add_argument("--from", dest="from_date", help="起始日期 YYYY-MM-DD")
|
||||||
import asyncio
|
parser.add_argument("--to", dest="to_date", help="截止日期 YYYY-MM-DD")
|
||||||
from playwright.async_api import async_playwright
|
parser.add_argument("--all-types", action="store_true", help="抓取所有公告类型(含中标/结果/变更类)")
|
||||||
except ImportError:
|
parser.add_argument("--force", action="store_true", help="强制重新抓取")
|
||||||
print(" ⚠️ Playwright 未安装,跳过 PDF 下载")
|
parser.add_argument("--keyword", default="", help="关键词过滤(标题/发布单位)")
|
||||||
return
|
parser.add_argument("--max-pages", dest="max_pages", type=int, default=MAX_PAGES_DEFAULT,
|
||||||
|
help=f"每板块最大翻页数(默认 {MAX_PAGES_DEFAULT})")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
print("\n[PDF] 正在下载详情页...")
|
today = date.today()
|
||||||
|
|
||||||
async def _run():
|
# 加载历史已下载记录(跨天去重)
|
||||||
async with async_playwright() as p:
|
today_str = today.strftime("%Y-%m-%d")
|
||||||
browser = await p.chromium.launch(headless=True)
|
accumulated_state, last_run_date = load_accumulated_state(OUTPUT_BASE, "xkgroup_downloaded.json", today_str)
|
||||||
success = 0
|
|
||||||
fail = 0
|
|
||||||
|
|
||||||
for i, notice in enumerate(notices):
|
if args.from_date:
|
||||||
notice_id = notice.get("id", "")
|
date_from = datetime.strptime(args.from_date, "%Y-%m-%d").date()
|
||||||
# 手机版页面含完整内容,桌面版只有摘要
|
elif not args.force and last_run_date:
|
||||||
pdf_url = notice.get("mobile_url", "") or notice.get("detail_url", "")
|
date_from = datetime.strptime(last_run_date, "%Y-%m-%d").date()
|
||||||
|
else:
|
||||||
|
date_from = today - timedelta(days=7)
|
||||||
|
|
||||||
if not notice_id or not pdf_url:
|
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:
|
||||||
|
print(f" ❌ 板块 {category} 抓取失败: {e}")
|
||||||
|
continue
|
||||||
|
print(f" ✅ 列表解析 {len(items)} 条")
|
||||||
|
|
||||||
|
type_new = 0
|
||||||
|
type_skip = 0
|
||||||
|
for item in items:
|
||||||
|
tag = item["tag"]
|
||||||
|
# 类型过滤
|
||||||
|
if tag and tag not in allowed_tags:
|
||||||
|
filtered_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 日期过滤
|
||||||
|
if not match_date_range(item["publish_date"], date_from, date_to):
|
||||||
|
filtered_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 关键词过滤
|
||||||
|
if args.keyword:
|
||||||
|
if args.keyword not in item["title"] and args.keyword not in item["org"]:
|
||||||
|
filtered_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
pub_date = notice.get("publish_time", "")[:10]
|
# 多次询价标题过滤 (二次/三次/四次...)
|
||||||
safe_title = re.sub(r"[\\/:*?\"<>|]", "_", notice["title"][:40])
|
if not RETRY_PATTERN.search(item["title"]):
|
||||||
base_name = f"{pub_date}_{notice_id}_{safe_title}"
|
filtered_count += 1
|
||||||
html_name = f"{base_name}.html"
|
continue
|
||||||
pdf_name = f"{base_name}.pdf"
|
|
||||||
html_path = os.path.join(html_dir, 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):
|
# 发布组织白名单(新站"发布单位"列常为代理机构,采购人名多在标题里,故标题+单位联合匹配)
|
||||||
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
|
||||||
|
|
||||||
print(f" [{i+1}/{len(notices)}] {notice['title'][:40]}...", end=" ", flush=True)
|
notice_id = item["id"]
|
||||||
|
|
||||||
try:
|
# 去重
|
||||||
page = await browser.new_page()
|
if not args.force and notice_id in state:
|
||||||
page.set_default_timeout(30000)
|
skip_count += 1
|
||||||
await page.goto(pdf_url, wait_until="domcontentloaded", timeout=25000)
|
type_skip += 1
|
||||||
await page.wait_for_timeout(2000)
|
continue
|
||||||
|
|
||||||
# 提取页面文本,解析金额
|
notice = {
|
||||||
page_text = await page.evaluate("() => document.body.innerText")
|
"id": notice_id,
|
||||||
amount = extract_amount_from_text(page_text)
|
"title": item["title"],
|
||||||
if amount and state is not None and notice_id in state:
|
"org": item["org"],
|
||||||
state[notice_id]["amount"] = amount
|
"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
|
||||||
|
|
||||||
# 保存 HTML 源码到 html/ 目录(供 AI 阅读)
|
print(f" 新增: {type_new} | 跳过: {type_skip}")
|
||||||
html_source = await page.content()
|
print()
|
||||||
with open(html_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(html_source)
|
|
||||||
|
|
||||||
# 获取实际内容高度(SPA 可能 overflow:hidden,scrollHeight 不准确)
|
# 保存状态(含 lastRun 时间戳,用于下次增量爬取)
|
||||||
content_height = await page.evaluate(
|
state["lastRun"] = datetime.now().isoformat()
|
||||||
"() => Math.max("
|
save_state(state_file, state)
|
||||||
" 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
|
|
||||||
|
|
||||||
# 更新状态文件,记录路径
|
# 生成清单
|
||||||
if state is not None and state_file and notice_id in state:
|
if all_notices:
|
||||||
state[notice_id]["pdf"] = rel_pdf
|
generate_markdown(all_notices, output_dir, date_range)
|
||||||
state[notice_id]["html"] = f"html/{html_name}"
|
|
||||||
with open(state_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ {e}")
|
|
||||||
fail += 1
|
|
||||||
|
|
||||||
await browser.close()
|
# 详情页增强:HTML 存档 + 金额/截止/地区提取 + 附件 PDF 转存
|
||||||
return success, fail
|
process_details(all_notices, output_dir, html_dir, pdf_dir, state, state_file)
|
||||||
|
|
||||||
import asyncio
|
print(f"\n========== 完成 ==========")
|
||||||
try:
|
print(f"✅ 新增: {new_count} | ⏩ 跳过: {skip_count} | 🔍 日期过滤: {filtered_count}")
|
||||||
success, fail = asyncio.run(_run())
|
print(f"📁 保存目录: {output_dir}")
|
||||||
if success:
|
|
||||||
print(f" 📄 PDF 下载完成: {success} 成功" + (f", {fail} 失败" if fail else ""))
|
|
||||||
except RuntimeError:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
success, fail = loop.run_until_complete(_run())
|
|
||||||
loop.close()
|
|
||||||
if success:
|
|
||||||
print(f" 📄 PDF 下载完成: {success} 成功" + (f", {fail} 失败" if fail else ""))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user