'use client' import { useEffect, useState } from 'react' import { API_BASE } from '@/lib/apiBase' interface WeightItem { label: string key: string value: number min: number max: number } interface Keyword { id: number word: string category: string weight: number matchCount: number enabled: boolean } interface ExampleLead { name: string amount: string score: number } interface DistributeRule { level: string label: string person: string } interface ExecutionLog { time: string lead: string rule: string result: string } interface CrawlerConfig { id: string name: string targetUrl: string status: string type: string sourceName: string | null scriptPath: string | null schedule: string | null keywords: string | null categories: string | null lastRunAt: string | null totalRuns: number successRuns: number failedRuns: number createdAt: string } const DEFAULT_WEIGHTS: WeightItem[] = [ { label: '预估金额权重', key: 'amount', value: 30, min: 0, max: 50 }, { label: '内容匹配度权重', key: 'match', value: 25, min: 0, max: 50 }, { label: '甲方级别权重', key: 'level', value: 20, min: 0, max: 50 }, { label: '时效性权重', key: 'timeliness', value: 15, min: 0, max: 50 }, { label: '历史成交权重', key: 'history', value: 10, min: 0, max: 50 }, ] const EXAMPLE_LEADS: ExampleLead[] = [ { name: '赵王新河治理工程块石采购', amount: '280万', score: 86 }, { name: '郑州地铁站改项目防火门采购', amount: '380万', score: 92 }, { 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[] = [ { level: 'S级', label: 'S级负责人', person: '张三' }, { level: 'A级', label: 'A级负责人', person: '李四' }, { level: 'B级', label: 'B级负责人', person: '王五' }, { level: 'C级', label: 'C级负责人', person: '赵六' }, { level: 'D级', label: 'D级负责人', person: '钱七' }, ] const EXECUTION_LOGS: ExecutionLog[] = [ { time: '2025-05-21 14:32', lead: '赵王新河治理工程块石采购', rule: 'S级自动分发', result: '已分配至张三' }, { time: '2025-05-21 11:15', lead: '南水北调隔离网商品混凝土采购', rule: 'S级自动分发', result: '已分配至张三' }, { time: '2025-05-20 16:48', lead: '郑州航空港区道路工程沥青采购', rule: '金额阈值评分', result: '评分更新至94分' }, { time: '2025-05-20 09:30', lead: '河南工程局抽水蓄能项目配电箱采购', rule: '品类加分规则', result: '匹配成功·加分15' }, { time: '2025-05-19 14:22', lead: '漯河分公司河道治理项目阀门采购', rule: 'B级自动分发', result: '已分配至王五' }, { time: '2025-05-19 10:05', lead: '新矿集团医院改造项目电线电缆采购', rule: 'A级自动分发', result: '已分配至李四' }, { time: '2025-05-18 17:00', lead: '十一局郑州地铁站改项目防火门采购', rule: '时效性加权', result: '投标日期加分生效' }, { time: '2025-05-18 13:45', lead: '洛阳地铁2号线机电安装工程采购', rule: 'A级自动分发', result: '已分配至李四' }, { time: '2025-05-17 09:20', lead: '中电建华中总部基地钢筋采购', rule: '历史成交加分', result: '成交历史加分生效' }, { time: '2025-05-16 15:30', lead: '三门峡水库加固工程钢材采购', rule: '金额阈值评分', result: '评分更新至72分' }, ] const CRAWLER_API = `${API_BASE}/api/crawlers` const TYPE_OPTIONS = [ { value: 'bid', label: '通用招采' }, { value: 'bid_powerchina', label: '中电建招采' }, { value: 'bid_xkgroup', label: '新矿集团招采' }, { value: 'bid_cnncecp', label: '中核集团招采' }, ] const CRON_PRESETS = [ { label: '每 6 个小时', value: '0 */6 * * *' }, { label: '每天早 8 点', value: '0 8 * * *' }, { label: '每天晚 7 点', value: '0 19 * * *' }, { label: '手动(不自动执行)', value: '' }, ] function getLevelClass(level: string) { const map: Record = { 'S级': 'bg-blue-50 text-blue-700 border-blue-200', 'A级': 'bg-amber-50 text-amber-700 border-amber-200', 'B级': 'bg-slate-100 text-slate-600 border-slate-200', 'C级': 'bg-rose-50 text-rose-600 border-rose-200', 'D级': 'bg-slate-50 text-slate-400 border-slate-200', } return map[level] || 'bg-slate-100 text-slate-600 border-slate-200' } 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 [sAmount, setSAmount] = useState(100) const [sCoop, setSCoop] = useState(3) const [aAmount, setAAmount] = useState(50) const [aCoop, setACoop] = useState(1) const [bAmount, setBAmount] = useState(10) const [cAmount, setCAmount] = useState(1) const [autoDistribute, setAutoDistribute] = useState(true) const [distribution, setDistribution] = useState(DEFAULT_DISTRIBUTION) const [showLogs, setShowLogs] = useState(false) const [crawlers, setCrawlers] = useState([]) const [crawlersLoading, setCrawlersLoading] = useState(true) const [editCrawlerId, setEditCrawlerId] = useState(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 [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 [runModal, setRunModal] = useState<{ id: string; name: string } | null>(null) const [runResult, setRunResult] = useState<'running' | 'success' | 'error'>('running') const totalWeight = weights.reduce((s, w) => s + w.value, 0) const handleWeightChange = (changedKey: string, newValue: number) => { setWeights(prev => { const changed = prev.find(w => w.key === changedKey) if (!changed) return prev const others = prev.filter(w => w.key !== changedKey) const oldOthersSum = others.reduce((s, w) => s + w.value, 0) const remaining = 100 - newValue let result = prev.map(w => { if (w.key === changedKey) return { ...w, value: newValue } if (oldOthersSum === 0) return { ...w, value: Math.floor(remaining / others.length) } return { ...w, value: Math.round(remaining * w.value / oldOthersSum) } }) let sum = result.reduce((s, w) => s + w.value, 0) let diff = 100 - sum for (let i = 0; i < result.length && diff !== 0; i++) { if (result[i].key === changedKey) continue const adjusted = result[i].value + diff if (adjusted >= result[i].min && adjusted <= result[i].max) { result[i] = { ...result[i], value: adjusted } diff = 0 } } if (diff !== 0) { result = result.map(w => { if (w.key === changedKey) { const adjusted = w.value + diff return { ...w, value: Math.max(w.min, Math.min(w.max, adjusted)) } } return w }) } return result }) } const computeNewScore = (currentScore: number) => { const w = weights.reduce((acc, item) => ({ ...acc, [item.key]: item.value }), {} as Record) const delta = (w.amount - 30) * 0.3 + (w.match - 25) * 0.25 + (w.level - 20) * 0.2 + (w.timeliness - 15) * 0.15 + (w.history - 10) * 0.1 return Math.round(Math.max(0, Math.min(100, currentScore + delta))) } const handleSave = () => { setSaved(true) 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 toggleKeyword = (id: number) => { setKeywords(prev => prev.map(k => k.id === id ? { ...k, enabled: !k.enabled } : k)) } const deleteKeyword = (id: number) => { setKeywords(prev => prev.filter(k => k.id !== id)) } 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 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 handleDistributeChange = (level: string, person: string) => { setDistribution(prev => prev.map(r => r.level === level ? { ...r, person } : r)) } const formulaText = `综合评分 = 预估金额×${weights.find(w => w.key === 'amount')?.value}% + 匹配度×${weights.find(w => w.key === 'match')?.value}% + 甲方级别×${weights.find(w => w.key === 'level')?.value}% + 时效性×${weights.find(w => w.key === 'timeliness')?.value}% + 历史成交×${weights.find(w => w.key === 'history')?.value}%` useEffect(() => { fetch(CRAWLER_API) .then(r => r.json()) .then((data: CrawlerConfig[]) => setCrawlers(data)) .catch(() => {}) .finally(() => setCrawlersLoading(false)) }, []) const startEditCrawler = (c: CrawlerConfig) => { setEditCrawlerId(c.id) setEditForm({ name: c.name, targetUrl: c.targetUrl, type: c.type, sourceName: c.sourceName || '', scriptPath: c.scriptPath || '', schedule: c.schedule || '', keywords: c.keywords ? (() => { try { return JSON.parse(c.keywords).join(', ') } catch { return c.keywords } })() : '', categories: c.categories ? (() => { try { return JSON.parse(c.categories).join(', ') } catch { return c.categories } })() : '', }) } const saveCrawler = async (id: string) => { const payload: Record = { name: editForm.name, targetUrl: editForm.targetUrl, type: editForm.type, sourceName: editForm.sourceName || null, scriptPath: editForm.scriptPath || null } if (editForm.schedule) payload.schedule = editForm.schedule const kwArr = editForm.keywords.split(/[,,、\s]+/).filter(Boolean) payload.keywords = kwArr.length > 0 ? JSON.stringify(kwArr) : '' const catArr = editForm.categories.split(/[,,、\s]+/).filter(Boolean) payload.categories = catArr.length > 0 ? JSON.stringify(catArr) : '' try { const res = await fetch(`${CRAWLER_API}/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!res.ok) throw new Error() const updated: CrawlerConfig = await res.json() setCrawlers(prev => prev.map(c => c.id === id ? updated : c)) setEditCrawlerId(null) } catch { alert('保存失败') } } const toggleCrawlerStatus = async (id: string, currentStatus: string) => { const action = currentStatus === 'active' ? 'pause' : 'resume' try { await fetch(`${CRAWLER_API}/${id}/${action}`, { method: 'POST' }) setCrawlers(prev => prev.map(c => c.id === id ? { ...c, status: action === 'pause' ? 'paused' : 'active' } : c)) } catch { alert('操作失败') } } const runCrawler = async (id: string, name: string) => { setRunResult('running') setRunModal({ id, name }) try { await fetch(`${CRAWLER_API}/${id}/run`, { method: 'POST' }) setRunResult('success') } catch { setRunResult('error') } } const addCrawler = async () => { if (!addForm.name.trim() || !addForm.targetUrl.trim()) return const kwArr = addForm.keywords.split(/[,,、\s]+/).filter(Boolean) const catArr = addForm.categories.split(/[,,、\s]+/).filter(Boolean) try { const res = await fetch(CRAWLER_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: addForm.name, targetUrl: addForm.targetUrl, type: addForm.type, sourceName: addForm.sourceName || null, scriptPath: addForm.scriptPath || null, schedule: addForm.schedule || null, keywords: kwArr.length > 0 ? JSON.stringify(kwArr) : null, categories: catArr.length > 0 ? JSON.stringify(catArr) : null, }), }) if (!res.ok) throw new Error() const created: CrawlerConfig = await res.json() setCrawlers(prev => [...prev, created]) setShowAddModal(false) setAddForm({ name: '', targetUrl: '', type: 'bid', sourceName: '', scriptPath: '', schedule: '0 */6 * * *', keywords: '', categories: '' }) } catch { alert('创建失败') } } const getStatusStyle = (status: string) => { const map: Record = { active: 'bg-emerald-50 text-emerald-700 border-emerald-200', paused: 'bg-amber-50 text-amber-700 border-amber-200', error: 'bg-rose-50 text-rose-700 border-rose-200', } return map[status] || 'bg-slate-100 text-slate-500' } const getTypeLabel = (type: string) => TYPE_OPTIONS.find(t => t.value === type)?.label || type const getCronLabel = (cron: string | null) => { if (!cron) return '手动' return CRON_PRESETS.find(p => p.value === cron)?.label || cron } return (
{/* Header */}

规则中心

维护线索评分权重,支持手动调整并触发重算

{/* Warning Banner */}

规则变更提醒

修改权重后会影响全部待处理线索评分,请确认后再发布。

{/* Tabs */}
{[ { key: 'weights' as const, label: '评分权重配置' }, { key: 'keywords' as const, label: '关键词库管理' }, { key: 'client' as const, label: '甲方分级规则' }, { key: 'crawlers' as const, label: '采集器配置' }, ].map(tab => ( ))}
{/* Tab 1: 评分权重配置 */} {activeTab === 'weights' && (
{/* Formula Display */}

评分权重配置

标准模型 V2.0
综合评分计算公式

{formulaText}

{weights.map(w => (
{w.value}%
handleWeightChange(w.key, parseInt(e.target.value))} className="w-full accent-secondary" />
{w.min}% {w.max}%
))}
权重合计 {totalWeight}% {totalWeight === 100 && ✓ 已平衡}
{/* Impact Preview */}

影响预估

{EXAMPLE_LEADS.map((lead, i) => { const newScore = computeNewScore(lead.score) const diff = newScore - lead.score return (

{lead.name}

预估金额 {lead.amount}

当前
{lead.score}
调整后
0 ? 'text-emerald-600' : diff < 0 ? 'text-rose-600' : ''}`}>{newScore}
0 ? 'bg-emerald-50 text-emerald-700' : diff < 0 ? 'bg-rose-50 text-rose-700' : 'bg-slate-100 text-slate-500'}`}> {diff > 0 ? '+' : ''}{diff}分
) })}
{/* Sidebar */}

影响概览

待重算线索
{keywords.length > 0 ? '1,248' : '1,248'}
预计评分上升
+{(() => { const avgDiff = EXAMPLE_LEADS.reduce((s, l) => s + computeNewScore(l.score) - l.score, 0) / EXAMPLE_LEADS.length return avgDiff > 0 ? avgDiff.toFixed(1) : '0.0' })()}%
预计评分下降
-{(() => { const avgDiff = EXAMPLE_LEADS.reduce((s, l) => s + computeNewScore(l.score) - l.score, 0) / EXAMPLE_LEADS.length return avgDiff < 0 ? Math.abs(avgDiff).toFixed(1) : '0.0' })()}%

版本历史

{[ { v: 'V2.0', time: '04-26 18:10', current: true }, { v: 'V1.9', time: '04-20 14:30', current: false }, { v: 'V1.8', time: '04-12 09:15', current: false }, { v: 'V1.7', time: '04-03 16:45', current: false }, ].map((ver, i) => (
{ver.v} {ver.current && 当前}
{ver.time} {!ver.current && ( )}
))}
)} {/* 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" />
{/* Keyword Table */}

关键词列表

{keywords.length === 0 ? ( ) : ( keywords.map(kw => ( )) )}
关键词 所属品类 权重 匹配次数 启用状态 操作
暂无关键词,请添加
{kw.word} {kw.category} {kw.weight} {kw.matchCount}
|
共 {keywords.length} 个关键词 {keywords.length > 0 && ( 启用 {keywords.filter(k => k.enabled).length} 个 · 停用 {keywords.filter(k => !k.enabled).length} 个 )}
)} {/* Tab 4: 采集器配置 */} {activeTab === 'crawlers' && (
{/* Header */}

管理招采信息采集器,配置抓取时间和过滤关键词

{/* Crawler List */}
{crawlersLoading ? (
加载中...
) : crawlers.length === 0 ? (
暂无采集器配置
) : ( {crawlers.map(c => ( {editCrawlerId === c.id ? ( <> ) : ( <> )} ))}
采集器名称 目标网址 抓取定时 过滤关键词 状态 运行统计 操作
setEditForm(f => ({ ...f, name: e.target.value }))} className="w-full border border-border-subtle rounded px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-secondary/20" /> setEditForm(f => ({ ...f, targetUrl: e.target.value }))} className="w-full border border-border-subtle rounded px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-secondary/20" /> 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" /> {{ active: '运行中', paused: '已暂停', error: '异常' }[c.status] || c.status} 成功 {c.successRuns}/{c.totalRuns} 次
{c.name} {c.targetUrl} {getCronLabel(c.schedule)} {c.keywords ? (
{(JSON.parse(c.keywords) as string[]).slice(0, 3).map((kw, i) => ( {kw} ))} {(JSON.parse(c.keywords) as string[]).length > 3 && ( +{JSON.parse(c.keywords).length - 3} )}
) : 全部}
成功 {c.successRuns} / {c.totalRuns} 次
{c.failedRuns > 0 &&
失败 {c.failedRuns} 次
} {c.lastRunAt &&
上次: {c.lastRunAt.slice(0, 16)}
}
)}
{/* Cron Reference */}

Cron 定时说明

分 0-59 时 0-23 日 1-31 月 1-12 周 0-7 (0/7=日) 示例: 0 8 * * 1-5 = 工作日早8点
)} {/* Tab 3: 甲方分级规则 */} {activeTab === 'client' && (
{/* Criteria Card */}

分级标准说明

{[ { level: 'S级', label: '核心跟踪', desc: '高优先级推送,专属 BD 跟进', color: 'bg-blue-700 text-white', textColor: 'text-blue-100' }, { level: 'A级', label: '重点跟踪', desc: '正常优先级,自动分配负责人', color: 'bg-amber-50 border border-amber-200 text-amber-700', textColor: 'text-amber-600' }, { level: 'B级', label: '探查跟踪', desc: '需人工探查后决定', color: 'bg-slate-50 border border-slate-200', textColor: 'text-slate-500' }, { level: 'C级', label: '常规跟踪', desc: '低优先级,系统自动推送', color: 'bg-rose-50 border border-rose-200 text-rose-600', textColor: 'text-rose-500' }, { level: 'D级', label: '暂不跟踪', desc: '不符合任何分级条件', color: 'bg-slate-50 border border-slate-200 text-slate-400', textColor: 'text-slate-400' }, ].map(item => (
{item.level}
{item.label}
{item.desc}
))}
{/* Threshold Configuration */}

阈值配置

{/* S级 */}
S级 核心跟踪
{sAmount}万
setSAmount(parseInt(e.target.value))} className="w-full accent-blue-700" />
{sCoop}次
setSCoop(parseInt(e.target.value))} className="w-full accent-blue-700" />
{/* A级 */}
A级 重点跟踪
{aAmount}万
setAAmount(parseInt(e.target.value))} className="w-full accent-amber-500" />
{aCoop}次
setACoop(parseInt(e.target.value))} className="w-full accent-amber-500" />
{/* B级 */}
B级 探查跟踪
{bAmount}万
setBAmount(parseInt(e.target.value))} className="w-full accent-slate-500" />
{/* C级 */}
C级 常规跟踪
{cAmount}万
setCAmount(parseFloat(e.target.value))} className="w-full accent-rose-400" />
{/* D级 */}
D级 暂不跟踪 —— 不符合以上任何分级条件的线索自动归入 D 级
{/* Auto Distribution */}

自动分发策略

启用自动分发
{distribution.map(r => ( ))}
级别 说明 负责人
{r.level} {r.label}
{/* Execution Logs */}
{showLogs && (
{EXECUTION_LOGS.map((log, i) => ( ))}
时间 线索 匹配规则 结果
{log.time} {log.lead} {log.rule} {log.result}
)}
)} {/* Batch Import Modal */} {showBatchModal && (
setShowBatchModal(false)}>
e.stopPropagation()}>

批量导入关键词

每行一个关键词,将自动归入默认品类(可后续编辑)