- TitleCompressorService: 发布前超长标题(>50字)调 MiniMax 压缩(保留公司名+物料+采购类型), JSON结构化输出对抗推理模型思考噪音, 失败硬截断兜底 - publishToDirev 集成标题压缩, 保留原50字预校验作为防御 - crawler resolveKeywords: 采集器配置优先按关联品类展开关键词库, 中电建每关键词独立状态文件 - InquiryPage: 新增关键词搜索(fSearch → GET /api/leads?search=) - RuleCenterPage: 采集器配置由 keywords 改为品类多选框(categories JSON数组)
1204 lines
68 KiB
TypeScript
1204 lines
68 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { API_BASE } from '@/lib/apiBase'
|
||
|
||
interface WeightItem {
|
||
label: string
|
||
key: string
|
||
value: number
|
||
min: number
|
||
max: number
|
||
}
|
||
|
||
interface 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>
|
||
)
|
||
}
|