1206 lines
68 KiB
TypeScript
1206 lines
68 KiB
TypeScript
'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<string, string> = {
|
||
'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<WeightItem[]>(DEFAULT_WEIGHTS)
|
||
const [keywords, setKeywords] = useState<Keyword[]>(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<DistributeRule[]>(DEFAULT_DISTRIBUTION)
|
||
const [showLogs, setShowLogs] = useState(false)
|
||
|
||
const [crawlers, setCrawlers] = useState<CrawlerConfig[]>([])
|
||
const [crawlersLoading, setCrawlersLoading] = useState(true)
|
||
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 [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<string, number>)
|
||
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<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
|
||
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<string, string> = {
|
||
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 (
|
||
<div className="max-w-[1400px] mx-auto space-y-6">
|
||
{/* Header */}
|
||
<div className="flex justify-between items-end">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-on-background">规则中心</h1>
|
||
<p className="text-sm text-on-surface-variant mt-1">维护线索评分权重,支持手动调整并触发重算</p>
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => alert('规则版本已提交,待审批发布')}
|
||
className="px-4 py-2 bg-white border border-border-subtle text-on-surface-variant rounded-lg text-sm flex items-center gap-2 hover:bg-surface-container-low 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="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" /></svg>
|
||
发布新版本
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
className="px-4 py-2 bg-secondary text-white rounded-lg text-sm flex items-center gap-2 hover:opacity-90 transition-opacity shadow-md"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" /></svg>
|
||
{saved ? '已保存' : '保存并重算评分'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Warning Banner */}
|
||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex items-start gap-3">
|
||
<svg className="w-5 h-5 text-amber-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>
|
||
<div>
|
||
<p className="font-semibold text-amber-800 text-sm">规则变更提醒</p>
|
||
<p className="text-sm text-amber-700 mt-1">修改权重后会影响全部待处理线索评分,请确认后再发布。</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
|
||
{[
|
||
{ key: 'weights' as const, label: '评分权重配置' },
|
||
{ key: 'keywords' as const, label: '关键词库管理' },
|
||
{ key: 'client' as const, label: '甲方分级规则' },
|
||
{ key: 'crawlers' as const, label: '采集器配置' },
|
||
].map(tab => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key)}
|
||
className={`px-5 py-2 text-sm font-semibold rounded-md transition-all ${
|
||
activeTab === tab.key
|
||
? 'bg-secondary text-white shadow-sm'
|
||
: 'text-on-surface-variant hover:text-on-background'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Tab 1: 评分权重配置 */}
|
||
{activeTab === 'weights' && (
|
||
<div className="grid grid-cols-12 gap-4">
|
||
<div className="col-span-8 space-y-4">
|
||
{/* Formula Display */}
|
||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
||
<div className="flex items-center justify-between mb-5">
|
||
<h3 className="text-base font-semibold">评分权重配置</h3>
|
||
<span className="px-3 py-1 bg-blue-50 text-blue-700 rounded-full text-xs font-bold">标准模型 V2.0</span>
|
||
</div>
|
||
<div className="bg-slate-50 rounded-lg p-3 mb-5">
|
||
<div className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase mb-2">综合评分计算公式</div>
|
||
<p className="text-sm font-medium text-on-background">{formulaText}</p>
|
||
</div>
|
||
<div className="space-y-5">
|
||
{weights.map(w => (
|
||
<div key={w.key}>
|
||
<div className="flex justify-between items-center mb-2">
|
||
<label className="text-sm font-semibold text-on-background">{w.label}</label>
|
||
<span className="text-sm font-bold text-secondary">{w.value}%</span>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={w.min}
|
||
max={w.max}
|
||
value={w.value}
|
||
onChange={e => handleWeightChange(w.key, parseInt(e.target.value))}
|
||
className="w-full accent-secondary"
|
||
/>
|
||
<div className="flex justify-between text-[10px] text-on-surface-variant mt-0.5">
|
||
<span>{w.min}%</span>
|
||
<span>{w.max}%</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-4 pt-4 border-t border-border-subtle flex justify-between items-center text-sm">
|
||
<span className="text-on-surface-variant">权重合计</span>
|
||
<span className={`font-bold text-lg ${totalWeight === 100 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||
{totalWeight}%
|
||
{totalWeight === 100 && <span className="ml-2 text-xs text-emerald-500 font-normal">✓ 已平衡</span>}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Impact Preview */}
|
||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
||
<h3 className="text-base font-semibold mb-4">影响预估</h3>
|
||
<div className="space-y-3">
|
||
{EXAMPLE_LEADS.map((lead, i) => {
|
||
const newScore = computeNewScore(lead.score)
|
||
const diff = newScore - lead.score
|
||
return (
|
||
<div key={i} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-border-subtle">
|
||
<div className="flex-1">
|
||
<p className="text-sm font-medium">{lead.name}</p>
|
||
<p className="text-xs text-on-surface-variant">预估金额 {lead.amount}</p>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<div className="text-center">
|
||
<div className="text-[10px] text-on-surface-variant">当前</div>
|
||
<div className="text-sm font-bold">{lead.score}</div>
|
||
</div>
|
||
<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="M13 7l5 5m0 0l-5 5m5-5H6" /></svg>
|
||
<div className="text-center">
|
||
<div className="text-[10px] text-on-surface-variant">调整后</div>
|
||
<div className={`text-sm font-bold ${diff > 0 ? 'text-emerald-600' : diff < 0 ? 'text-rose-600' : ''}`}>{newScore}</div>
|
||
</div>
|
||
<div className={`text-xs font-semibold px-2 py-1 rounded ${diff > 0 ? 'bg-emerald-50 text-emerald-700' : diff < 0 ? 'bg-rose-50 text-rose-700' : 'bg-slate-100 text-slate-500'}`}>
|
||
{diff > 0 ? '+' : ''}{diff}分
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<div className="col-span-4 space-y-4">
|
||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
||
<h3 className="text-base font-semibold mb-4">影响概览</h3>
|
||
<div className="space-y-3">
|
||
<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-2xl font-bold mt-1">
|
||
{keywords.length > 0 ? '1,248' : '1,248'}
|
||
</div>
|
||
</div>
|
||
<div className="p-4 rounded-lg bg-emerald-50 border border-emerald-200">
|
||
<div className="text-xs text-emerald-700">预计评分上升</div>
|
||
<div className="text-2xl font-bold mt-1 text-emerald-700">
|
||
+{(() => {
|
||
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'
|
||
})()}%
|
||
</div>
|
||
</div>
|
||
<div className="p-4 rounded-lg bg-rose-50 border border-rose-200">
|
||
<div className="text-xs text-rose-700">预计评分下降</div>
|
||
<div className="text-2xl font-bold mt-1 text-rose-700">
|
||
-{(() => {
|
||
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'
|
||
})()}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
||
<h3 className="text-base font-semibold mb-4">版本历史</h3>
|
||
<div className="space-y-2">
|
||
{[
|
||
{ 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) => (
|
||
<div key={i} className={`flex justify-between items-center p-2 rounded ${ver.current ? 'bg-blue-50 border border-blue-100' : 'hover:bg-slate-50'}`}>
|
||
<div>
|
||
<span className={`font-semibold text-sm ${ver.current ? 'text-blue-700' : ''}`}>{ver.v}</span>
|
||
{ver.current && <span className="text-[10px] text-blue-400 ml-2">当前</span>}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-on-surface-variant">{ver.time}</span>
|
||
{!ver.current && (
|
||
<button onClick={() => alert(`已回滚至 ${ver.v}`)} className="text-xs text-blue-700 hover:underline">回滚</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 2: 关键词库管理 */}
|
||
{activeTab === 'keywords' && (
|
||
<div className="space-y-4">
|
||
{/* Add Keyword Row */}
|
||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
||
<h3 className="text-base font-semibold mb-4">添加关键词</h3>
|
||
<div className="flex items-end gap-3">
|
||
<div className="flex-1">
|
||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">关键词</span>
|
||
<input
|
||
type="text"
|
||
value={newWord}
|
||
onChange={e => 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"
|
||
/>
|
||
</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>
|
||
|
||
{/* Keyword Table */}
|
||
<div className="bg-white rounded-xl border border-border-subtle shadow-sm overflow-hidden">
|
||
<div className="p-4 border-b border-border-subtle flex justify-between items-center">
|
||
<h3 className="text-base font-semibold">关键词列表</h3>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setShowBatchModal(true)}
|
||
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-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>
|
||
批量导入
|
||
</button>
|
||
<button
|
||
onClick={() => setShowClearModal(true)}
|
||
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"
|
||
>
|
||
<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>
|
||
一键清空
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<table className="w-full text-left">
|
||
<thead className="bg-slate-50 border-b border-border-subtle">
|
||
<tr>
|
||
<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 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right">权重</th>
|
||
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right">匹配次数</th>
|
||
<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>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border-subtle">
|
||
{keywords.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={6} className="p-8 text-center text-sm text-on-surface-variant">暂无关键词,请添加</td>
|
||
</tr>
|
||
) : (
|
||
keywords.map(kw => (
|
||
<tr key={kw.id} className="hover:bg-slate-50 transition-colors">
|
||
<td className="p-4 text-sm font-medium">{kw.word}</td>
|
||
<td className="p-4 text-sm text-on-surface-variant">
|
||
<span className="px-2 py-0.5 bg-slate-100 text-on-surface-variant text-[10px] font-medium rounded">{kw.category}</span>
|
||
</td>
|
||
<td className="p-4 text-sm text-right font-semibold">{kw.weight}</td>
|
||
<td className="p-4 text-sm text-right text-on-surface-variant">{kw.matchCount}</td>
|
||
<td className="p-4">
|
||
<button
|
||
onClick={() => toggleKeyword(kw.id)}
|
||
className={`relative w-9 h-5 rounded-full transition-colors ${kw.enabled ? 'bg-secondary' : 'bg-slate-300'}`}
|
||
>
|
||
<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'}`} />
|
||
</button>
|
||
</td>
|
||
<td className="p-4">
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={() => editKeyword(kw.id)} className="text-xs text-blue-600 hover:underline">编辑</button>
|
||
<span className="text-slate-300">|</span>
|
||
<button onClick={() => deleteKeyword(kw.id)} className="text-xs text-rose-500 hover:underline">删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
<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">共 {keywords.length} 个关键词</span>
|
||
{keywords.length > 0 && (
|
||
<span className="text-[10px] text-on-surface-variant">
|
||
启用 {keywords.filter(k => k.enabled).length} 个 · 停用 {keywords.filter(k => !k.enabled).length} 个
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 4: 采集器配置 */}
|
||
{activeTab === 'crawlers' && (
|
||
<div className="space-y-4">
|
||
{/* Header */}
|
||
<div className="flex justify-between items-center">
|
||
<p className="text-sm text-on-surface-variant">管理招采信息采集器,配置抓取时间和过滤关键词</p>
|
||
<button
|
||
onClick={() => setShowAddModal(true)}
|
||
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"
|
||
>
|
||
<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>
|
||
添加采集器
|
||
</button>
|
||
</div>
|
||
|
||
{/* Crawler List */}
|
||
<div className="bg-white rounded-xl border border-border-subtle shadow-sm overflow-hidden">
|
||
{crawlersLoading ? (
|
||
<div className="p-8 text-center text-sm text-on-surface-variant">加载中...</div>
|
||
) : crawlers.length === 0 ? (
|
||
<div className="p-8 text-center text-sm text-on-surface-variant">暂无采集器配置</div>
|
||
) : (
|
||
<table className="w-full text-left">
|
||
<thead className="bg-slate-50 border-b border-border-subtle">
|
||
<tr>
|
||
<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>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border-subtle">
|
||
{crawlers.map(c => (
|
||
<tr key={c.id} className="hover:bg-slate-50 transition-colors">
|
||
{editCrawlerId === c.id ? (
|
||
<>
|
||
<td className="p-3">
|
||
<input value={editForm.name} onChange={e => 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" />
|
||
</td>
|
||
<td className="p-3">
|
||
<input value={editForm.targetUrl} onChange={e => 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" />
|
||
</td>
|
||
<td className="p-3">
|
||
<select value={editForm.schedule} onChange={e => setEditForm(f => ({ ...f, schedule: e.target.value }))} className="border border-border-subtle rounded px-2 py-1 text-xs outline-none max-w-[140px]">
|
||
{CRON_PRESETS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||
</select>
|
||
</td>
|
||
<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" />
|
||
</td>
|
||
<td className="p-3">
|
||
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full border ${getStatusStyle(c.status)}`}>
|
||
{{ active: '运行中', paused: '已暂停', error: '异常' }[c.status] || c.status}
|
||
</span>
|
||
</td>
|
||
<td className="p-3 text-xs text-on-surface-variant">
|
||
成功 {c.successRuns}/{c.totalRuns} 次
|
||
</td>
|
||
<td className="p-3">
|
||
<div className="flex gap-2">
|
||
<button onClick={() => saveCrawler(c.id)} className="text-xs text-emerald-600 hover:underline font-semibold">保存</button>
|
||
<button onClick={() => setEditCrawlerId(null)} className="text-xs text-slate-500 hover:underline">取消</button>
|
||
</div>
|
||
</td>
|
||
</>
|
||
) : (
|
||
<>
|
||
<td className="p-3 text-sm font-semibold">{c.name}</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">
|
||
{c.keywords ? (
|
||
<div className="flex flex-wrap gap-1">
|
||
{(JSON.parse(c.keywords) as string[]).slice(0, 3).map((kw, i) => (
|
||
<span key={i} className="px-1.5 py-0.5 bg-blue-50 text-blue-600 text-[10px] rounded">{kw}</span>
|
||
))}
|
||
{(JSON.parse(c.keywords) as string[]).length > 3 && (
|
||
<span className="text-[10px] text-slate-400">+{JSON.parse(c.keywords).length - 3}</span>
|
||
)}
|
||
</div>
|
||
) : <span className="text-xs text-slate-400">全部</span>}
|
||
</td>
|
||
<td className="p-3">
|
||
<button
|
||
onClick={() => toggleCrawlerStatus(c.id, 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}
|
||
</button>
|
||
</td>
|
||
<td className="p-3 text-xs text-on-surface-variant">
|
||
<div>成功 <span className="font-semibold text-emerald-600">{c.successRuns}</span> / {c.totalRuns} 次</div>
|
||
{c.failedRuns > 0 && <div>失败 <span className="font-semibold text-rose-500">{c.failedRuns}</span> 次</div>}
|
||
{c.lastRunAt && <div className="text-[10px] text-slate-400">上次: {c.lastRunAt.slice(0, 16)}</div>}
|
||
</td>
|
||
<td className="p-3">
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={() => runCrawler(c.id, c.name)} className="text-xs text-blue-600 hover:underline flex items-center gap-1">
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||
运行
|
||
</button>
|
||
<button onClick={() => startEditCrawler(c)} className="text-xs text-slate-500 hover:underline">编辑</button>
|
||
</div>
|
||
</td>
|
||
</>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
{/* Cron Reference */}
|
||
<div className="bg-white rounded-xl border border-border-subtle p-4 shadow-sm">
|
||
<h4 className="text-xs font-semibold text-on-surface-variant mb-2">Cron 定时说明</h4>
|
||
<div className="grid grid-cols-4 gap-2 text-[10px] text-on-surface-variant">
|
||
<span><b>分</b> 0-59</span>
|
||
<span><b>时</b> 0-23</span>
|
||
<span><b>日</b> 1-31</span>
|
||
<span><b>月</b> 1-12</span>
|
||
<span><b>周</b> 0-7 (0/7=日)</span>
|
||
<span className="col-span-3">示例: <code className="bg-slate-100 px-1 rounded">0 8 * * 1-5</code> = 工作日早8点</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab 3: 甲方分级规则 */}
|
||
{activeTab === 'client' && (
|
||
<div className="space-y-4">
|
||
{/* Criteria Card */}
|
||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
||
<h3 className="text-base font-semibold mb-4">分级标准说明</h3>
|
||
<div className="grid grid-cols-5 gap-3">
|
||
{[
|
||
{ 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 => (
|
||
<div key={item.level} className={`p-3 rounded-lg ${item.color}`}>
|
||
<div className={`font-bold text-lg`}>{item.level}</div>
|
||
<div className={`text-xs font-semibold ${item.textColor}`}>{item.label}</div>
|
||
<div className={`text-[10px] mt-1 ${item.textColor}`}>{item.desc}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Threshold Configuration */}
|
||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
||
<h3 className="text-base font-semibold mb-4">阈值配置</h3>
|
||
<div className="space-y-4">
|
||
{/* S级 */}
|
||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="px-2 py-0.5 bg-blue-700 text-white text-xs font-bold rounded">S级</span>
|
||
<span className="text-sm font-semibold text-blue-800">核心跟踪</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div>
|
||
<div className="flex justify-between items-center mb-1">
|
||
<label className="text-xs font-medium text-blue-700">预估金额 ≥</label>
|
||
<span className="text-sm font-bold text-blue-700">{sAmount}万</span>
|
||
</div>
|
||
<input type="range" min={50} max={500} step={10} value={sAmount} onChange={e => setSAmount(parseInt(e.target.value))} className="w-full accent-blue-700" />
|
||
</div>
|
||
<div>
|
||
<div className="flex justify-between items-center mb-1">
|
||
<label className="text-xs font-medium text-blue-700">年合作次数 ≥</label>
|
||
<span className="text-sm font-bold text-blue-700">{sCoop}次</span>
|
||
</div>
|
||
<input type="range" min={1} max={10} value={sCoop} onChange={e => setSCoop(parseInt(e.target.value))} className="w-full accent-blue-700" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* A级 */}
|
||
<div className="p-4 bg-amber-50 rounded-lg border border-amber-100">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="px-2 py-0.5 bg-amber-500 text-white text-xs font-bold rounded">A级</span>
|
||
<span className="text-sm font-semibold text-amber-800">重点跟踪</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div>
|
||
<div className="flex justify-between items-center mb-1">
|
||
<label className="text-xs font-medium text-amber-700">预估金额 ≥</label>
|
||
<span className="text-sm font-bold text-amber-700">{aAmount}万</span>
|
||
</div>
|
||
<input type="range" min={10} max={200} step={10} value={aAmount} onChange={e => setAAmount(parseInt(e.target.value))} className="w-full accent-amber-500" />
|
||
</div>
|
||
<div>
|
||
<div className="flex justify-between items-center mb-1">
|
||
<label className="text-xs font-medium text-amber-700">年合作次数 ≥</label>
|
||
<span className="text-sm font-bold text-amber-700">{aCoop}次</span>
|
||
</div>
|
||
<input type="range" min={1} max={10} value={aCoop} onChange={e => setACoop(parseInt(e.target.value))} className="w-full accent-amber-500" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* B级 */}
|
||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="px-2 py-0.5 bg-slate-500 text-white text-xs font-bold rounded">B级</span>
|
||
<span className="text-sm font-semibold text-slate-700">探查跟踪</span>
|
||
</div>
|
||
<div className="max-w-[50%]">
|
||
<div className="flex justify-between items-center mb-1">
|
||
<label className="text-xs font-medium text-slate-600">预估金额 ≥</label>
|
||
<span className="text-sm font-bold text-slate-600">{bAmount}万</span>
|
||
</div>
|
||
<input type="range" min={1} max={50} step={1} value={bAmount} onChange={e => setBAmount(parseInt(e.target.value))} className="w-full accent-slate-500" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* C级 */}
|
||
<div className="p-4 bg-rose-50 rounded-lg border border-rose-100">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="px-2 py-0.5 bg-rose-400 text-white text-xs font-bold rounded">C级</span>
|
||
<span className="text-sm font-semibold text-rose-700">常规跟踪</span>
|
||
</div>
|
||
<div className="max-w-[50%]">
|
||
<div className="flex justify-between items-center mb-1">
|
||
<label className="text-xs font-medium text-rose-600">预估金额 ≥</label>
|
||
<span className="text-sm font-bold text-rose-600">{cAmount}万</span>
|
||
</div>
|
||
<input type="range" min={0.5} max={10} step={0.5} value={cAmount} onChange={e => setCAmount(parseFloat(e.target.value))} className="w-full accent-rose-400" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* D级 */}
|
||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200 border-dashed">
|
||
<div className="flex items-center gap-2">
|
||
<span className="px-2 py-0.5 bg-slate-300 text-white text-xs font-bold rounded">D级</span>
|
||
<span className="text-sm font-semibold text-slate-500">暂不跟踪</span>
|
||
<span className="text-xs text-slate-400 ml-2">—— 不符合以上任何分级条件的线索自动归入 D 级</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Auto Distribution */}
|
||
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-base font-semibold">自动分发策略</h3>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-on-surface-variant">启用自动分发</span>
|
||
<button
|
||
onClick={() => setAutoDistribute(!autoDistribute)}
|
||
className={`relative w-10 h-6 rounded-full transition-colors ${autoDistribute ? 'bg-secondary' : 'bg-slate-300'}`}
|
||
>
|
||
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoDistribute ? 'translate-x-4' : 'translate-x-0'}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<table className="w-full text-left">
|
||
<thead className="bg-slate-50 border-b border-border-subtle">
|
||
<tr>
|
||
<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>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border-subtle">
|
||
{distribution.map(r => (
|
||
<tr key={r.level} className="hover:bg-slate-50 transition-colors">
|
||
<td className="p-3"><span className={`px-2 py-0.5 text-[10px] font-bold rounded border ${getLevelClass(r.level)}`}>{r.level}</span></td>
|
||
<td className="p-3 text-sm text-on-surface-variant">{r.label}</td>
|
||
<td className="p-3">
|
||
<select
|
||
value={r.person}
|
||
onChange={e => handleDistributeChange(r.level, e.target.value)}
|
||
disabled={!autoDistribute}
|
||
className="bg-surface-container-low border border-border-subtle rounded px-3 py-1.5 text-sm focus:ring-secondary outline-none disabled:opacity-50"
|
||
>
|
||
{PERSONS.map(p => <option key={p}>{p}</option>)}
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Execution Logs */}
|
||
<div className="bg-white rounded-xl border border-border-subtle shadow-sm">
|
||
<button
|
||
onClick={() => setShowLogs(!showLogs)}
|
||
className="w-full p-4 flex items-center justify-between hover:bg-slate-50 transition-colors"
|
||
>
|
||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||
<svg className="w-4 h-4 text-on-surface-variant" 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>
|
||
规则执行日志
|
||
</h3>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-on-surface-variant">最近 10 条</span>
|
||
<svg className={`w-4 h-4 text-on-surface-variant transition-transform ${showLogs ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
||
</div>
|
||
</button>
|
||
{showLogs && (
|
||
<div className="border-t border-border-subtle">
|
||
<table className="w-full text-left">
|
||
<thead className="bg-slate-50">
|
||
<tr>
|
||
<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>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border-subtle">
|
||
{EXECUTION_LOGS.map((log, i) => (
|
||
<tr key={i} className="hover:bg-slate-50 transition-colors text-sm">
|
||
<td className="p-3 text-on-surface-variant">{log.time}</td>
|
||
<td className="p-3 font-medium">{log.lead}</td>
|
||
<td className="p-3">
|
||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 text-[10px] font-medium rounded">{log.rule}</span>
|
||
</td>
|
||
<td className="p-3 text-on-surface-variant">{log.result}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</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 */}
|
||
{showAddModal && (
|
||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowAddModal(false)}>
|
||
<div className="bg-white rounded-xl shadow-2xl w-[520px] 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={() => setShowAddModal(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="grid grid-cols-2 gap-4">
|
||
<div className="col-span-2">
|
||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">采集器名称</span>
|
||
<input value={addForm.name} onChange={e => setAddForm(f => ({ ...f, name: 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>
|
||
<div className="col-span-2">
|
||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">目标网址</span>
|
||
<input value={addForm.targetUrl} onChange={e => setAddForm(f => ({ ...f, targetUrl: e.target.value }))} placeholder="https://..." 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>
|
||
<div>
|
||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">来源名称</span>
|
||
<input value={addForm.sourceName} onChange={e => setAddForm(f => ({ ...f, sourceName: 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>
|
||
<div>
|
||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">脚本文件名</span>
|
||
<input value={addForm.scriptPath} onChange={e => setAddForm(f => ({ ...f, scriptPath: e.target.value }))} placeholder="如:fetch-cnncecp.py(留空自动推断)" 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>
|
||
<div className="col-span-2">
|
||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">抓取定时</span>
|
||
<select value={addForm.schedule} onChange={e => setAddForm(f => ({ ...f, schedule: e.target.value }))} className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm outline-none">
|
||
{CRON_PRESETS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">过滤关键词(逗号分隔,留空表示抓取全部)</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>
|
||
<div className="col-span-2">
|
||
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">所属品类(逗号分隔,留空表示全部品类)</span>
|
||
<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" />
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-3 pt-2">
|
||
<button onClick={addCrawler} disabled={!addForm.name.trim() || !addForm.targetUrl.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={() => setShowAddModal(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>
|
||
)}
|
||
|
||
{/* Run Crawler Modal */}
|
||
{runModal && (
|
||
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => runResult !== 'running' ? setRunModal(null) : undefined}>
|
||
<div className="bg-white rounded-xl shadow-2xl w-[420px] transform" onClick={e => e.stopPropagation()}>
|
||
<div className="p-6 text-center space-y-4">
|
||
{runResult === 'running' ? (
|
||
<>
|
||
<div className="mx-auto w-12 h-12 border-4 border-secondary/20 border-t-secondary rounded-full animate-spin" />
|
||
<div>
|
||
<h3 className="text-base font-semibold">抓取任务已提交</h3>
|
||
<p className="text-sm text-on-surface-variant mt-2">
|
||
「{runModal.name}」正在后台执行抓取,请稍候...
|
||
</p>
|
||
</div>
|
||
<button onClick={() => setRunModal(null)} className="w-full py-2.5 bg-white border border-border-subtle text-on-surface rounded-lg text-sm font-semibold hover:bg-surface-container-low transition-colors">
|
||
后台运行
|
||
</button>
|
||
</>
|
||
) : runResult === 'success' ? (
|
||
<>
|
||
<div className="mx-auto w-12 h-12 bg-emerald-50 rounded-full flex items-center justify-center">
|
||
<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.5} d="M5 13l4 4L19 7" /></svg>
|
||
</div>
|
||
<div>
|
||
<h3 className="text-base font-semibold text-emerald-700">抓取完成</h3>
|
||
<p className="text-sm text-on-surface-variant mt-2">
|
||
「{runModal.name}」抓取任务已执行完毕,新线索已写入线索池。
|
||
</p>
|
||
</div>
|
||
<button onClick={() => setRunModal(null)} className="w-full py-2.5 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90">知道了</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="mx-auto w-12 h-12 bg-rose-50 rounded-full flex items-center justify-center">
|
||
<svg className="w-6 h-6 text-rose-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" /></svg>
|
||
</div>
|
||
<div>
|
||
<h3 className="text-base font-semibold text-rose-700">抓取失败</h3>
|
||
<p className="text-sm text-on-surface-variant mt-2">请检查脚本配置或查看后端日志。</p>
|
||
</div>
|
||
<button onClick={() => setRunModal(null)} className="w-full py-2.5 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90">关闭</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</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>
|
||
)
|
||
}
|