'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 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 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 0 */6 * * *' }, { label: '每天早 8 点', value: '0 0 8 * * *' }, { label: '每天晚 7 点', value: '0 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 [saved, setSaved] = 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) 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 fetchCategories = () => { setCatLoading(true) fetch(`${API_BASE}/api/categories`) .then(r => r.json()) .then(data => setCategories(data)) .catch(() => {}) .finally(() => setCatLoading(false)) } // 品类管理 - 添加 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 startEditCat = (cat: any) => { setEditCatId(cat.id) setEditCatName(cat.name) setEditCatKeywords((() => { try { return (JSON.parse(cat.keywords) as string[]).join(', ') } catch { return cat.keywords || '' } })()) } 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 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) => { 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)) }, []) // 初次加载品类列表 useEffect(() => { fetchCategories() }, []) 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: '', 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 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 catArr: string[] = editForm.categories ? JSON.parse(editForm.categories) : [] payload.categories = catArr.length > 0 ? JSON.stringify(catArr) : null // 新配置不再使用 keywords 字段,但不清理旧数据(resolveKeywords 有 fallback) 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 catArr: string[] = addForm.categories ? JSON.parse(addForm.categories) : [] 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: 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 */}

影响概览

待重算线索
{'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' && (
{/* Header */}

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

{/* 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" />
关键词列表(逗号分隔)