platform-assistant/apps/web/src/components/RuleCenterPage.tsx

1206 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}