platform-assistant/apps/web/src/components/RuleCenterPage.tsx
Sisyphus eb15849168 feat: 询价标题AI压缩 + 采集器品类化 + 询价搜索与品类多选框
- TitleCompressorService: 发布前超长标题(>50字)调 MiniMax 压缩(保留公司名+物料+采购类型), JSON结构化输出对抗推理模型思考噪音, 失败硬截断兜底
- publishToDirev 集成标题压缩, 保留原50字预校验作为防御
- crawler resolveKeywords: 采集器配置优先按关联品类展开关键词库, 中电建每关键词独立状态文件
- InquiryPage: 新增关键词搜索(fSearch → GET /api/leads?search=)
- RuleCenterPage: 采集器配置由 keywords 改为品类多选框(categories JSON数组)
2026-09-30 09:34:58 +08:00

1204 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 ExampleLead {
name: string
amount: string
score: number
}
interface DistributeRule {
level: string
label: string
person: string
}
interface ExecutionLog {
time: string
lead: string
rule: string
result: string
}
interface CrawlerConfig {
id: string
name: string
targetUrl: string
status: string
type: string
sourceName: string | null
scriptPath: string | null
schedule: string | null
keywords: string | null
categories: string | null
lastRunAt: string | null
totalRuns: number
successRuns: number
failedRuns: number
createdAt: string
}
const DEFAULT_WEIGHTS: WeightItem[] = [
{ label: '预估金额权重', key: 'amount', value: 30, min: 0, max: 50 },
{ label: '内容匹配度权重', key: 'match', value: 25, min: 0, max: 50 },
{ label: '甲方级别权重', key: 'level', value: 20, min: 0, max: 50 },
{ label: '时效性权重', key: 'timeliness', value: 15, min: 0, max: 50 },
{ label: '历史成交权重', key: 'history', value: 10, min: 0, max: 50 },
]
const EXAMPLE_LEADS: ExampleLead[] = [
{ name: '赵王新河治理工程块石采购', amount: '280万', score: 86 },
{ name: '郑州地铁站改项目防火门采购', amount: '380万', score: 92 },
{ name: '信阳乡村振兴供水项目管材采购', amount: '65万', score: 45 },
]
const PERSONS = ['张三', '李四', '王五', '赵六', '钱七']
const DEFAULT_DISTRIBUTION: DistributeRule[] = [
{ level: 'S级', label: 'S级负责人', person: '张三' },
{ level: 'A级', label: 'A级负责人', person: '李四' },
{ level: 'B级', label: 'B级负责人', person: '王五' },
{ level: 'C级', label: 'C级负责人', person: '赵六' },
{ level: 'D级', label: 'D级负责人', person: '钱七' },
]
const EXECUTION_LOGS: ExecutionLog[] = [
{ time: '2025-05-21 14:32', lead: '赵王新河治理工程块石采购', rule: 'S级自动分发', result: '已分配至张三' },
{ time: '2025-05-21 11:15', lead: '南水北调隔离网商品混凝土采购', rule: 'S级自动分发', result: '已分配至张三' },
{ time: '2025-05-20 16:48', lead: '郑州航空港区道路工程沥青采购', rule: '金额阈值评分', result: '评分更新至94分' },
{ time: '2025-05-20 09:30', lead: '河南工程局抽水蓄能项目配电箱采购', rule: '品类加分规则', result: '匹配成功·加分15' },
{ time: '2025-05-19 14:22', lead: '漯河分公司河道治理项目阀门采购', rule: 'B级自动分发', result: '已分配至王五' },
{ time: '2025-05-19 10:05', lead: '新矿集团医院改造项目电线电缆采购', rule: 'A级自动分发', result: '已分配至李四' },
{ time: '2025-05-18 17:00', lead: '十一局郑州地铁站改项目防火门采购', rule: '时效性加权', result: '投标日期加分生效' },
{ time: '2025-05-18 13:45', lead: '洛阳地铁2号线机电安装工程采购', rule: 'A级自动分发', result: '已分配至李四' },
{ time: '2025-05-17 09:20', lead: '中电建华中总部基地钢筋采购', rule: '历史成交加分', result: '成交历史加分生效' },
{ time: '2025-05-16 15:30', lead: '三门峡水库加固工程钢材采购', rule: '金额阈值评分', result: '评分更新至72分' },
]
const CRAWLER_API = `${API_BASE}/api/crawlers`
const TYPE_OPTIONS = [
{ value: 'bid', label: '通用招采' },
{ value: 'bid_powerchina', label: '中电建招采' },
{ value: 'bid_xkgroup', label: '新矿集团招采' },
{ value: 'bid_cnncecp', label: '中核集团招采' },
]
const CRON_PRESETS = [
{ label: '每 6 个小时', value: '0 0 */6 * * *' },
{ label: '每天早 8 点', value: '0 0 8 * * *' },
{ label: '每天晚 7 点', value: '0 0 19 * * *' },
{ label: '手动(不自动执行)', value: '' },
]
function getLevelClass(level: string) {
const map: Record<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 [saved, setSaved] = useState(false)
// 品类管理
const [categories, setCategories] = useState<any[]>([])
const [catLoading, setCatLoading] = useState(true)
const [showAddCat, setShowAddCat] = useState(false)
const [addCatName, setAddCatName] = useState('')
const [addCatKeywords, setAddCatKeywords] = useState('')
const [editCatId, setEditCatId] = useState<string | null>(null)
const [editCatName, setEditCatName] = useState('')
const [editCatKeywords, setEditCatKeywords] = useState('')
const [sAmount, setSAmount] = useState(100)
const [sCoop, setSCoop] = useState(3)
const [aAmount, setAAmount] = useState(50)
const [aCoop, setACoop] = useState(1)
const [bAmount, setBAmount] = useState(10)
const [cAmount, setCAmount] = useState(1)
const [autoDistribute, setAutoDistribute] = useState(true)
const [distribution, setDistribution] = useState<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 fetchCategories = () => {
setCatLoading(true)
fetch(`${API_BASE}/api/categories`)
.then(r => r.json())
.then(data => setCategories(data))
.catch(() => {})
.finally(() => setCatLoading(false))
}
// 品类管理 - 添加
const handleAddCategory = async () => {
if (!addCatName.trim()) return
const kwArr = addCatKeywords.split(/[,,、\s]+/).filter(Boolean)
try {
const res = await fetch(`${API_BASE}/api/categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: addCatName.trim(), keywords: kwArr }),
})
if (!res.ok) throw new Error()
const created = await res.json()
setCategories(prev => [...prev, created])
setShowAddCat(false)
setAddCatName('')
setAddCatKeywords('')
} catch { alert('添加品类失败') }
}
// 品类管理 - 编辑
const startEditCat = (cat: any) => {
setEditCatId(cat.id)
setEditCatName(cat.name)
setEditCatKeywords((() => {
try { return (JSON.parse(cat.keywords) as string[]).join(', ') } catch { return cat.keywords || '' }
})())
}
const handleUpdateCategory = async () => {
if (!editCatId || !editCatName.trim()) return
const kwArr = editCatKeywords.split(/[,,、\s]+/).filter(Boolean)
try {
const res = await fetch(`${API_BASE}/api/categories/${editCatId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editCatName.trim(), keywords: kwArr }),
})
if (!res.ok) throw new Error()
const updated = await res.json()
setCategories(prev => prev.map(c => c.id === editCatId ? updated : c))
setEditCatId(null)
} catch { alert('更新品类失败') }
}
// 品类管理 - 删除
const handleDeleteCategory = async (id: string, name: string) => {
if (!confirm(`确定要删除品类「${name}」吗?已有线索的该品类不受影响。`)) return
try {
await fetch(`${API_BASE}/api/categories/${id}`, { method: 'DELETE' })
setCategories(prev => prev.filter(c => c.id !== id))
} catch { alert('删除品类失败') }
}
const handleDistributeChange = (level: string, person: string) => {
setDistribution(prev => prev.map(r => r.level === level ? { ...r, person } : r))
}
const formulaText = `综合评分 = 预估金额×${weights.find(w => w.key === 'amount')?.value}% + 匹配度×${weights.find(w => w.key === 'match')?.value}% + 甲方级别×${weights.find(w => w.key === 'level')?.value}% + 时效性×${weights.find(w => w.key === 'timeliness')?.value}% + 历史成交×${weights.find(w => w.key === 'history')?.value}%`
useEffect(() => {
fetch(CRAWLER_API)
.then(r => r.json())
.then((data: CrawlerConfig[]) => setCrawlers(data))
.catch(() => {})
.finally(() => setCrawlersLoading(false))
}, [])
// 初次加载品类列表
useEffect(() => { fetchCategories() }, [])
const startEditCrawler = (c: CrawlerConfig) => {
setEditCrawlerId(c.id)
setEditForm({
name: c.name,
targetUrl: c.targetUrl,
type: c.type,
sourceName: c.sourceName || '',
scriptPath: c.scriptPath || '',
schedule: c.schedule || '',
keywords: '',
categories: c.categories ? (() => { try { const p = JSON.parse(c.categories); return Array.isArray(p) ? JSON.stringify(p) : '[]' } catch { return '[]' } })() : '[]',
})
}
const saveCrawler = async (id: string) => {
const payload: Record<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 catArr: string[] = editForm.categories ? JSON.parse(editForm.categories) : []
payload.categories = catArr.length > 0 ? JSON.stringify(catArr) : null
// 新配置不再使用 keywords 字段,但不清理旧数据(resolveKeywords 有 fallback)
try {
const res = await fetch(`${CRAWLER_API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error()
const updated: CrawlerConfig = await res.json()
setCrawlers(prev => prev.map(c => c.id === id ? updated : c))
setEditCrawlerId(null)
} catch { alert('保存失败') }
}
const toggleCrawlerStatus = async (id: string, currentStatus: string) => {
const action = currentStatus === 'active' ? 'pause' : 'resume'
try {
await fetch(`${CRAWLER_API}/${id}/${action}`, { method: 'POST' })
setCrawlers(prev => prev.map(c => c.id === id ? { ...c, status: action === 'pause' ? 'paused' : 'active' } : c))
} catch { alert('操作失败') }
}
const runCrawler = async (id: string, name: string) => {
setRunResult('running')
setRunModal({ id, name })
try {
await fetch(`${CRAWLER_API}/${id}/run`, { method: 'POST' })
setRunResult('success')
} catch { setRunResult('error') }
}
const addCrawler = async () => {
if (!addForm.name.trim() || !addForm.targetUrl.trim()) return
const catArr: string[] = addForm.categories ? JSON.parse(addForm.categories) : []
try {
const res = await fetch(CRAWLER_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: addForm.name,
targetUrl: addForm.targetUrl,
type: addForm.type,
sourceName: addForm.sourceName || null,
scriptPath: addForm.scriptPath || null,
schedule: addForm.schedule || null,
keywords: null,
categories: catArr.length > 0 ? JSON.stringify(catArr) : null,
}),
})
if (!res.ok) throw new Error()
const created: CrawlerConfig = await res.json()
setCrawlers(prev => [...prev, created])
setShowAddModal(false)
setAddForm({ name: '', targetUrl: '', type: 'bid', sourceName: '', scriptPath: '', schedule: '0 */6 * * *', keywords: '', categories: '[]' })
} catch { alert('创建失败') }
}
const getStatusStyle = (status: string) => {
const map: Record<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">
{'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">
{/* Header */}
<div className="flex justify-between items-center">
<p className="text-sm text-on-surface-variant">
管理所属品类及其关键词。采集公告时优先按关键词匹配到对应品类,匹配不到则自动 AI 推断。
</p>
<button
onClick={() => setShowAddCat(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>
{/* Add Category Modal */}
{showAddCat && (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-20 bg-black/30" onClick={() => setShowAddCat(false)}>
<div className="bg-white rounded-xl shadow-2xl w-[500px] 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={() => setShowAddCat(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>
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">品类名称</span>
<input
value={addCatName}
onChange={e => setAddCatName(e.target.value)}
placeholder="如:彩板房"
className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-secondary/20"
/>
</div>
<div>
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">关键词列表(逗号分隔)</span>
<textarea
value={addCatKeywords}
onChange={e => setAddCatKeywords(e.target.value)}
placeholder="彩板房, 彩钢板, 夹芯板"
rows={3}
className="w-full border border-border-subtle rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-secondary/20 resize-none"
/>
<p className="text-[10px] text-on-surface-variant mt-1">公告标题包含任一关键词即自动归入该品类</p>
</div>
<div className="flex gap-3 pt-2">
<button onClick={handleAddCategory} disabled={!addCatName.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={() => setShowAddCat(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>
)}
{/* Category Table */}
<div className="bg-white rounded-xl border border-border-subtle shadow-sm overflow-hidden">
{catLoading ? (
<div className="p-8 text-center text-sm text-on-surface-variant">加载中...</div>
) : categories.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-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 w-20 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">来源</th>
<th className="p-4 w-40 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-border-subtle">
{categories.map(cat => {
const kwList: string[] = (() => { try { return JSON.parse(cat.keywords) } catch { return [] } })()
return editCatId === cat.id ? (
<tr key={cat.id} className="bg-blue-50/30">
<td className="p-3">
<input value={editCatName} onChange={e => setEditCatName(e.target.value)} className="w-full border border-border-subtle rounded px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-secondary/20" />
</td>
<td className="p-3">
<input value={editCatKeywords} onChange={e => setEditCatKeywords(e.target.value)} placeholder="逗号分隔" className="w-full border border-border-subtle rounded px-2 py-1.5 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 ${cat.isAIGenerated ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'}`}>
{cat.isAIGenerated ? 'AI' : '手动'}
</span>
</td>
<td className="p-3">
<div className="flex gap-2">
<button onClick={handleUpdateCategory} className="px-3 py-1 text-[11px] bg-emerald-50 text-emerald-700 border border-emerald-200 rounded-md font-semibold hover:bg-emerald-100">保存</button>
<button onClick={() => setEditCatId(null)} className="px-3 py-1 text-[11px] bg-white border border-border-subtle rounded-md text-slate-600 hover:bg-slate-50">取消</button>
</div>
</td>
</tr>
) : (
<tr key={cat.id} className="hover:bg-slate-50 transition-colors">
<td className="p-4 text-sm font-semibold">{cat.name}</td>
<td className="p-4">
<div className="flex flex-wrap gap-1 max-w-[500px]">
{kwList.length === 0 ? (
<span className="text-xs text-slate-400">无关键词</span>
) : (
kwList.map((kw: string, i: number) => (
<span key={i} className="px-1.5 py-0.5 bg-slate-100 text-slate-700 text-[10px] rounded whitespace-nowrap">{kw}</span>
))
)}
</div>
</td>
<td className="p-4">
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${cat.isAIGenerated ? 'bg-amber-50 text-amber-700' : 'bg-blue-50 text-blue-700'}`}>
{cat.isAIGenerated ? 'AI' : '手动'}
</span>
</td>
<td className="p-4">
<div className="flex items-center gap-2">
<button onClick={() => startEditCat(cat)} className="text-xs text-blue-600 hover:underline">编辑</button>
<span className="text-slate-300">|</span>
<button onClick={() => handleDeleteCategory(cat.id, cat.name)} className="text-xs text-rose-500 hover:underline">删除</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
{!catLoading && categories.length > 0 && (
<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">共 {categories.length} 个品类</span>
<span className="text-[10px] text-on-surface-variant">
手动 {categories.filter(c => !c.isAIGenerated).length} 个 · AI 创建 {categories.filter(c => c.isAIGenerated).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">
<div className="max-h-[150px] overflow-y-auto space-y-1 text-xs">
{categories.length === 0 ? (
<span className="text-slate-400">请先在「关键词库管理」添加品类</span>
) : categories.map(cat => {
const current: string[] = editForm.categories ? JSON.parse(editForm.categories) : []
const selected = current.includes(cat.name)
return (
<label key={cat.id} className="flex items-center gap-1.5 cursor-pointer hover:bg-slate-50 rounded p-0.5">
<input
type="checkbox"
checked={selected}
onChange={() => {
const next = selected ? current.filter(n => n !== cat.name) : [...current, cat.name]
setEditForm(f => ({ ...f, categories: JSON.stringify(next) }))
}}
className="accent-secondary"
/>
{cat.name}
</label>
)
})}
</div>
</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.categories ? (
<div className="flex flex-wrap gap-1">
{(JSON.parse(c.categories) as string[]).slice(0, 3).map((cat, i) => (
<span key={i} className="px-1.5 py-0.5 bg-purple-50 text-purple-600 text-[10px] rounded">{cat}</span>
))}
{(JSON.parse(c.categories) as string[]).length > 3 && (
<span className="text-[10px] text-slate-400">+{JSON.parse(c.categories).length - 3}</span>
)}
</div>
) : c.keywords ? (
<div className="flex flex-wrap gap-1 items-center">
{(JSON.parse(c.keywords) as string[]).slice(0, 3).map((kw, i) => (
<span key={i} className="px-1.5 py-0.5 bg-slate-100 text-slate-500 text-[10px] rounded">{kw}</span>
))}
<span className="text-[10px] text-slate-400">(旧)</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>
)}
{/* 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-2">关联品类(选中的品类关键词用于过滤公告)</span>
<div className="max-h-[200px] overflow-y-auto border border-border-subtle rounded-lg p-3 space-y-2">
{categories.length === 0 ? (
<p className="text-xs text-on-surface-variant">暂无品类,请先在「关键词库管理」中添加品类</p>
) : categories.map(cat => {
const current: string[] = addForm.categories ? JSON.parse(addForm.categories) : []
const selected = current.includes(cat.name)
return (
<label key={cat.id} className="flex items-center gap-2 cursor-pointer hover:bg-slate-50 p-1 rounded">
<input
type="checkbox"
checked={selected}
onChange={() => {
const next = selected ? current.filter(n => n !== cat.name) : [...current, cat.name]
setAddForm(f => ({ ...f, categories: JSON.stringify(next) }))
}}
className="accent-secondary"
/>
<span className="text-sm">{cat.name}</span>
{(() => { try { const k = JSON.parse(cat.keywords); return Array.isArray(k) ? k : [] } catch { return [] } })().length > 0 && (
<span className="text-[10px] text-on-surface-variant">
({(() => { try { const k = JSON.parse(cat.keywords); return Array.isArray(k) ? k.slice(0, 3).join(', ') : '' } catch { return '' } })()})
</span>
)}
</label>
)
})}
</div>
</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>
)}
</div>
)
}