Compare commits
5 Commits
a822d7782b
...
4d5521e365
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d5521e365 | ||
|
|
ad7e739b83 | ||
|
|
9702d4b8ae | ||
|
|
0d2dce6b73 | ||
|
|
8a3c96598b |
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,6 +8,7 @@ dist/
|
|||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
build/
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Local data
|
# Local data
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@ -216,7 +216,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`)
|
||||||
|
|||||||
@ -4,13 +4,14 @@ import { IntegrationModule } from '../integration/integration.module'
|
|||||||
import { CrawlerModule } from '../crawler/crawler.module'
|
import { CrawlerModule } from '../crawler/crawler.module'
|
||||||
import { ProductCategoryModule } from '../product-category/product-category.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 { LeadController } from './lead.controller'
|
import { LeadController } from './lead.controller'
|
||||||
import { LeadNotesController } from './lead-notes.controller'
|
import { LeadNotesController } from './lead-notes.controller'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, IntegrationModule, CrawlerModule, ProductCategoryModule],
|
imports: [PrismaModule, IntegrationModule, CrawlerModule, ProductCategoryModule],
|
||||||
controllers: [LeadController, LeadNotesController],
|
controllers: [LeadController, LeadNotesController],
|
||||||
providers: [LeadService],
|
providers: [LeadService, MaterialExtractorService],
|
||||||
exports: [LeadService],
|
exports: [LeadService],
|
||||||
})
|
})
|
||||||
export class LeadModule {}
|
export class LeadModule {}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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 { ProductCategoryService } from '../product-category/product-category.service'
|
||||||
import { CategoryInferenceService } from '../product-category/category-inference.service'
|
import { CategoryInferenceService } from '../product-category/category-inference.service'
|
||||||
|
import { MaterialExtractorService } from './material-extractor.service'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LeadService {
|
export class LeadService {
|
||||||
@ -13,6 +14,7 @@ export class LeadService {
|
|||||||
private integrationService: IntegrationService,
|
private integrationService: IntegrationService,
|
||||||
private categoryService: ProductCategoryService,
|
private categoryService: ProductCategoryService,
|
||||||
private categoryInference: CategoryInferenceService,
|
private categoryInference: CategoryInferenceService,
|
||||||
|
private materialExtractor: MaterialExtractorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string; timeRange?: string }) {
|
async findAll(params?: { status?: string; level?: string; search?: string; star?: string; source?: string; category?: string; assignedTo?: string; timeRange?: string }) {
|
||||||
@ -69,10 +71,9 @@ export class LeadService {
|
|||||||
}
|
}
|
||||||
return this.prisma.lead.findMany({
|
return this.prisma.lead.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [
|
// 最新入库优先:createdAt 降序为主,deadline 仅作同批次内的次序参考
|
||||||
{ deadline: 'desc' },
|
// (旧版用 deadline desc 会把截止日远/为空的最新数据沉到 700+ 名开外)
|
||||||
{ createdAt: 'desc' },
|
orderBy: [{ createdAt: 'desc' }, { deadline: 'desc' }],
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,6 +309,32 @@ export class LeadService {
|
|||||||
publishArgs.enterpriseName = `${b2bIntegration.name}公司`
|
publishArgs.enterpriseName = `${b2bIntegration.name}公司`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预校验: B2B 平台字段长度限制 (tb_inquiry.title varchar(50))
|
||||||
|
// 在调 MCP 之前先报错,避免拿到通用的"服务器内部异常"
|
||||||
|
const TITLE_MAX_LEN = 50
|
||||||
|
if (publishArgs.title && 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',
|
||||||
@ -331,9 +358,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}`
|
||||||
@ -443,4 +470,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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1002,6 +1002,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 +1338,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>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user