From f12722eed950680ed802b23ff249e3f486a14c04 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 23 Jul 2026 14:45:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=B3=E9=94=AE=E8=AF=8D=E5=BA=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86UI=20+=20=E5=93=81=E7=B1=BBCRUD=E5=AE=8C?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/lead/lead.service.ts | 4 +- .../category-inference.service.ts | 26 +- .../product-category.service.ts | 11 + apps/web/src/components/RuleCenterPage.tsx | 432 ++++++++---------- 4 files changed, 224 insertions(+), 249 deletions(-) diff --git a/apps/api/src/lead/lead.service.ts b/apps/api/src/lead/lead.service.ts index 15d3d2e..c872530 100644 --- a/apps/api/src/lead/lead.service.ts +++ b/apps/api/src/lead/lead.service.ts @@ -71,9 +71,7 @@ export class LeadService { } return this.prisma.lead.findMany({ where, - // 最新入库优先:createdAt 降序为主,deadline 仅作同批次内的次序参考 - // (旧版用 deadline desc 会把截止日远/为空的最新数据沉到 700+ 名开外) - orderBy: [{ createdAt: 'desc' }, { deadline: 'desc' }], + orderBy: [{ publishTime: 'desc' }, { createdAt: 'desc' }], }) } diff --git a/apps/api/src/product-category/category-inference.service.ts b/apps/api/src/product-category/category-inference.service.ts index a3717ef..406524e 100644 --- a/apps/api/src/product-category/category-inference.service.ts +++ b/apps/api/src/product-category/category-inference.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common' import { PrismaService } from '../prisma/prisma.service' -import { ProductCategoryService } from '../product-category/product-category.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' @@ -88,7 +88,10 @@ export class CategoryInferenceService { try { const aiCategory = sanitizeOne(await this.callMinimax(title, summary, apiKey)) if (aiCategory) { - await this.categoryService.addKeywordToCategory(aiCategory, this.extractMainKeyword(title)) + const keyword = this.extractMainKeyword(title) + if (keyword) { + await this.categoryService.addKeywordToCategory(aiCategory, keyword) + } this.logger.log(`AI 分类: "${title.slice(0, 40)}..." → ${aiCategory}`) return aiCategory } @@ -163,7 +166,10 @@ export class CategoryInferenceService { const catName = sanitizeOne(lines[j] || '') if (catName) { result.set(batch[j].id, catName) - await this.categoryService.addKeywordToCategory(catName, this.extractMainKeyword(batch[j].title)) + const keyword = this.extractMainKeyword(batch[j].title) + if (keyword) { + await this.categoryService.addKeywordToCategory(catName, keyword) + } } else { result.set(batch[j].id, '') } @@ -209,7 +215,17 @@ export class CategoryInferenceService { } private extractMainKeyword(title: string): string { - const match = title.match(/([\u4e00-\u9fa5]{2,6})采购/) - return match ? match[1] : title.slice(0, 20) + // 取最后一个"采购"前面的 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 } } diff --git a/apps/api/src/product-category/product-category.service.ts b/apps/api/src/product-category/product-category.service.ts index 9a26702..2870a47 100644 --- a/apps/api/src/product-category/product-category.service.ts +++ b/apps/api/src/product-category/product-category.service.ts @@ -1,6 +1,15 @@ 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 @@ -38,6 +47,7 @@ export class ProductCategoryService { 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' } } @@ -47,6 +57,7 @@ export class ProductCategoryService { } 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({ diff --git a/apps/web/src/components/RuleCenterPage.tsx b/apps/web/src/components/RuleCenterPage.tsx index 29b62fc..4bf9509 100644 --- a/apps/web/src/components/RuleCenterPage.tsx +++ b/apps/web/src/components/RuleCenterPage.tsx @@ -11,15 +11,6 @@ interface WeightItem { max: number } -interface Keyword { - id: number - word: string - category: string - weight: number - matchCount: number - enabled: boolean -} - interface ExampleLead { name: string amount: string @@ -71,26 +62,6 @@ const EXAMPLE_LEADS: ExampleLead[] = [ { 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 DEFAULT_DISTRIBUTION: DistributeRule[] = [ @@ -142,17 +113,16 @@ function getLevelClass(level: string) { export default function RuleCenterPage() { const [activeTab, setActiveTab] = useState<'weights' | 'keywords' | 'client' | 'crawlers'>('weights') const [weights, setWeights] = useState(DEFAULT_WEIGHTS) - const [keywords, setKeywords] = useState(MOCK_KEYWORDS) const [saved, setSaved] = useState(false) - const [nextKeywordId, setNextKeywordId] = useState(16) - - const [newWord, setNewWord] = useState('') - const [newCategory, setNewCategory] = useState(CATEGORIES[0]) - const [newWeight, setNewWeight] = useState(5) - - const [showBatchModal, setShowBatchModal] = useState(false) - const [batchText, setBatchText] = useState('') - const [showClearModal, setShowClearModal] = useState(false) + // 品类管理 + const [categories, setCategories] = useState([]) + const [catLoading, setCatLoading] = useState(true) + const [showAddCat, setShowAddCat] = useState(false) + const [addCatName, setAddCatName] = useState('') + const [addCatKeywords, setAddCatKeywords] = useState('') + const [editCatId, setEditCatId] = useState(null) + const [editCatName, setEditCatName] = useState('') + const [editCatKeywords, setEditCatKeywords] = useState('') const [sAmount, setSAmount] = useState(100) const [sCoop, setSCoop] = useState(3) @@ -231,53 +201,67 @@ export default function RuleCenterPage() { setTimeout(() => setSaved(false), 2000) } - const handleAddKeyword = () => { - if (!newWord.trim()) return - setKeywords(prev => [ - ...prev, - { id: nextKeywordId, word: newWord.trim(), category: newCategory, weight: newWeight, matchCount: 0, enabled: true }, - ]) - setNextKeywordId(prev => prev + 1) - setNewWord('') - setNewWeight(5) + // 品类管理 - 获取列表 + const fetchCategories = () => { + setCatLoading(true) + fetch(`${API_BASE}/api/categories`) + .then(r => r.json()) + .then(data => setCategories(data)) + .catch(() => {}) + .finally(() => setCatLoading(false)) } - 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 kw = keywords.find(k => k.id === id) - if (!kw) return - setNewWord(kw.word) - setNewCategory(kw.category) - setNewWeight(kw.weight) - setKeywords(prev => prev.filter(k => k.id !== id)) + const handleUpdateCategory = async () => { + if (!editCatId || !editCatName.trim()) return + const kwArr = editCatKeywords.split(/[,,、\s]+/).filter(Boolean) + try { + const res = await fetch(`${API_BASE}/api/categories/${editCatId}`, { + method: 'PUT', + 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 newKeywords: Keyword[] = lines.map((line, i) => ({ - id: nextKeywordId + i, - word: line, - category: '工程建材', - weight: 5, - matchCount: 0, - enabled: true, - })) - setKeywords(prev => [...prev, ...newKeywords]) - setNextKeywordId(prev => prev + lines.length) - setBatchText('') - setShowBatchModal(false) - } - - const handleClearAll = () => { - setKeywords([]) - setShowClearModal(false) + // 品类管理 - 删除 + const handleDeleteCategory = async (id: string, name: string) => { + if (!confirm(`确定要删除品类「${name}」吗?已有线索的该品类不受影响。`)) return + try { + await fetch(`${API_BASE}/api/categories/${id}`, { method: 'DELETE' }) + setCategories(prev => prev.filter(c => c.id !== id)) + } catch { alert('删除品类失败') } } const handleDistributeChange = (level: string, person: string) => { @@ -294,6 +278,9 @@ export default function RuleCenterPage() { .finally(() => setCrawlersLoading(false)) }, []) + // 初次加载品类列表 + useEffect(() => { fetchCategories() }, []) + const startEditCrawler = (c: CrawlerConfig) => { setEditCrawlerId(c.id) setEditForm({ @@ -532,7 +519,7 @@ export default function RuleCenterPage() {
待重算线索
- {keywords.length > 0 ? '1,248' : '1,248'} + {'1,248'}
@@ -587,125 +574,139 @@ export default function RuleCenterPage() { {/* Tab 2: 关键词库管理 */} {activeTab === 'keywords' && (
- {/* Add Keyword Row */} -
-

添加关键词

-
-
- 关键词 - setNewWord(e.target.value)} - placeholder="输入关键词" - className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none" - /> -
-
- 所属品类 - -
-
- 权重 (1-10) - 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" - /> -
- -
+ {/* Header */} +
+

+ 管理所属品类及其关键词。采集公告时优先按关键词匹配到对应品类,匹配不到则自动 AI 推断。 +

+
- {/* Keyword Table */} -
-
-

关键词列表

-
- - + {/* Add Category Modal */} + {showAddCat && ( +
setShowAddCat(false)}> +
e.stopPropagation()}> +
+

添加所属品类

+ +
+
+
+ 品类名称 + setAddCatName(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" + /> +
+
+ 关键词列表(逗号分隔) +