feat: 关键词库管理UI + 品类CRUD完善

This commit is contained in:
Sisyphus 2026-07-23 14:45:46 +08:00
parent fd8b197853
commit f12722eed9
4 changed files with 224 additions and 249 deletions

View File

@ -71,9 +71,7 @@ export class LeadService {
}
return this.prisma.lead.findMany({
where,
// 最新入库优先:createdAt 降序为主,deadline 仅作同批次内的次序参考
// (旧版用 deadline desc 会把截止日远/为空的最新数据沉到 700+ 名开外)
orderBy: [{ createdAt: 'desc' }, { deadline: 'desc' }],
orderBy: [{ publishTime: 'desc' }, { createdAt: 'desc' }],
})
}

View File

@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { ProductCategoryService } from '../product-category/product-category.service'
import { isGarbageKeyword, ProductCategoryService } from '../product-category/product-category.service'
const MINIMAX_ENDPOINT = 'https://api.minimaxi.com/v1/chat/completions'
const MINIMAX_MODEL = process.env.MINIMAX_MODEL || 'MiniMax-M2.7'
@ -88,7 +88,10 @@ export class CategoryInferenceService {
try {
const aiCategory = sanitizeOne(await this.callMinimax(title, summary, apiKey))
if (aiCategory) {
await this.categoryService.addKeywordToCategory(aiCategory, this.extractMainKeyword(title))
const keyword = this.extractMainKeyword(title)
if (keyword) {
await this.categoryService.addKeywordToCategory(aiCategory, keyword)
}
this.logger.log(`AI 分类: "${title.slice(0, 40)}..." → ${aiCategory}`)
return aiCategory
}
@ -163,7 +166,10 @@ export class CategoryInferenceService {
const catName = sanitizeOne(lines[j] || '')
if (catName) {
result.set(batch[j].id, catName)
await this.categoryService.addKeywordToCategory(catName, this.extractMainKeyword(batch[j].title))
const keyword = this.extractMainKeyword(batch[j].title)
if (keyword) {
await this.categoryService.addKeywordToCategory(catName, keyword)
}
} else {
result.set(batch[j].id, '')
}
@ -209,7 +215,17 @@ export class CategoryInferenceService {
}
private extractMainKeyword(title: string): string {
const match = title.match(/([\u4e00-\u9fa5]{2,6})采购/)
return match ? match[1] : title.slice(0, 20)
// 取最后一个"采购"前面的 2-6 个中文字(更靠近实际物料名)
let lastMatch = ''
const regex = /([\u4e00-\u9fa5]{2,6})采购/g
let match: RegExpExecArray | null
while ((match = regex.exec(title)) !== null) {
lastMatch = match[1]
}
if (!lastMatch) return ''
// 跳过公司名/组织机构名
if (isGarbageKeyword(lastMatch)) return ''
return lastMatch
}
}

View File

@ -1,6 +1,15 @@
import { Injectable, Logger } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
/** 判断是否是公司名/组织机构名等垃圾关键词,不应参与关键词匹配或反哺 */
export function isGarbageKeyword(kw: string): boolean {
if (/(?:公司|有限|集团|工程局|项目部)$/.test(kw)) return true
if (/^(?:中国电建|新疆)/.test(kw)) return true
const genericTerms = ['公开竞价', '公开招标', '竞争性谈判', '询价采购', '邀请招标']
if (genericTerms.includes(kw)) return true
return false
}
export interface CategoryMatch {
name: string
matchedBy: 'keyword' | 'ai' | null
@ -38,6 +47,7 @@ export class ProductCategoryService {
for (const cat of categories) {
const keywords: string[] = JSON.parse(cat.keywords)
for (const kw of keywords) {
if (isGarbageKeyword(kw)) continue
if (title.includes(kw)) {
return { name: cat.name, matchedBy: 'keyword' }
}
@ -47,6 +57,7 @@ export class ProductCategoryService {
}
async addKeywordToCategory(categoryName: string, keyword: string) {
if (!keyword || isGarbageKeyword(keyword)) return
const cat = await this.prisma.productCategory.findUnique({ where: { name: categoryName } })
if (!cat) {
await this.prisma.productCategory.create({

View File

@ -11,15 +11,6 @@ interface WeightItem {
max: number
}
interface Keyword {
id: number
word: string
category: string
weight: number
matchCount: number
enabled: boolean
}
interface ExampleLead {
name: string
amount: string
@ -71,26 +62,6 @@ const EXAMPLE_LEADS: ExampleLead[] = [
{ name: '信阳乡村振兴供水项目管材采购', amount: '65万', score: 45 },
]
const CATEGORIES = ['工程建材', '医疗器械', 'IT设备', '办公用品', '物流服务', '咨询服务']
const MOCK_KEYWORDS: Keyword[] = [
{ id: 1, word: '防火门', category: '工程建材', weight: 9, matchCount: 47, enabled: true },
{ id: 2, word: '配电箱', category: '工程建材', weight: 8, matchCount: 35, enabled: true },
{ id: 3, word: '电线电缆', category: '工程建材', weight: 8, matchCount: 42, enabled: true },
{ id: 4, word: '阀门', category: '工程建材', weight: 7, matchCount: 28, enabled: true },
{ id: 5, word: '钢材', category: '工程建材', weight: 9, matchCount: 56, enabled: true },
{ id: 6, word: '混凝土', category: '工程建材', weight: 7, matchCount: 33, enabled: true },
{ id: 7, word: 'CT设备', category: '医疗器械', weight: 8, matchCount: 12, enabled: true },
{ id: 8, word: 'MRI核磁共振', category: '医疗器械', weight: 9, matchCount: 8, enabled: true },
{ id: 9, word: '服务器', category: 'IT设备', weight: 7, matchCount: 15, enabled: true },
{ id: 10, word: '交换机', category: 'IT设备', weight: 6, matchCount: 11, enabled: true },
{ id: 11, word: '办公桌椅', category: '办公用品', weight: 4, matchCount: 23, enabled: false },
{ id: 12, word: '打印设备', category: '办公用品', weight: 5, matchCount: 19, enabled: true },
{ id: 13, word: '物流运输', category: '物流服务', weight: 6, matchCount: 31, enabled: true },
{ id: 14, word: '仓储服务', category: '物流服务', weight: 5, matchCount: 14, enabled: true },
{ id: 15, word: '管理咨询', category: '咨询服务', weight: 4, matchCount: 9, enabled: false },
]
const PERSONS = ['张三', '李四', '王五', '赵六', '钱七']
const DEFAULT_DISTRIBUTION: DistributeRule[] = [
@ -142,17 +113,16 @@ function getLevelClass(level: string) {
export default function RuleCenterPage() {
const [activeTab, setActiveTab] = useState<'weights' | 'keywords' | 'client' | 'crawlers'>('weights')
const [weights, setWeights] = useState<WeightItem[]>(DEFAULT_WEIGHTS)
const [keywords, setKeywords] = useState<Keyword[]>(MOCK_KEYWORDS)
const [saved, setSaved] = useState(false)
const [nextKeywordId, setNextKeywordId] = useState(16)
const [newWord, setNewWord] = useState('')
const [newCategory, setNewCategory] = useState(CATEGORIES[0])
const [newWeight, setNewWeight] = useState(5)
const [showBatchModal, setShowBatchModal] = useState(false)
const [batchText, setBatchText] = useState('')
const [showClearModal, setShowClearModal] = useState(false)
// 品类管理
const [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)
@ -231,53 +201,67 @@ export default function RuleCenterPage() {
setTimeout(() => setSaved(false), 2000)
}
const handleAddKeyword = () => {
if (!newWord.trim()) return
setKeywords(prev => [
...prev,
{ id: nextKeywordId, word: newWord.trim(), category: newCategory, weight: newWeight, matchCount: 0, enabled: true },
])
setNextKeywordId(prev => prev + 1)
setNewWord('')
setNewWeight(5)
// 品类管理 - 获取列表
const fetchCategories = () => {
setCatLoading(true)
fetch(`${API_BASE}/api/categories`)
.then(r => r.json())
.then(data => setCategories(data))
.catch(() => {})
.finally(() => setCatLoading(false))
}
const toggleKeyword = (id: number) => {
setKeywords(prev => prev.map(k => k.id === id ? { ...k, enabled: !k.enabled } : k))
// 品类管理 - 添加
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 deleteKeyword = (id: number) => {
setKeywords(prev => prev.filter(k => k.id !== id))
// 品类管理 - 编辑
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 editKeyword = (id: number) => {
const kw = keywords.find(k => k.id === id)
if (!kw) return
setNewWord(kw.word)
setNewCategory(kw.category)
setNewWeight(kw.weight)
setKeywords(prev => prev.filter(k => k.id !== id))
const 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 handleBatchImport = () => {
const lines = batchText.split('\n').map(l => l.trim()).filter(Boolean)
const newKeywords: Keyword[] = lines.map((line, i) => ({
id: nextKeywordId + i,
word: line,
category: '工程建材',
weight: 5,
matchCount: 0,
enabled: true,
}))
setKeywords(prev => [...prev, ...newKeywords])
setNextKeywordId(prev => prev + lines.length)
setBatchText('')
setShowBatchModal(false)
}
const handleClearAll = () => {
setKeywords([])
setShowClearModal(false)
// 品类管理 - 删除
const 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) => {
@ -294,6 +278,9 @@ export default function RuleCenterPage() {
.finally(() => setCrawlersLoading(false))
}, [])
// 初次加载品类列表
useEffect(() => { fetchCategories() }, [])
const startEditCrawler = (c: CrawlerConfig) => {
setEditCrawlerId(c.id)
setEditForm({
@ -532,7 +519,7 @@ export default function RuleCenterPage() {
<div className="p-4 rounded-lg bg-slate-50 border border-border-subtle">
<div className="text-xs text-on-surface-variant">待重算线索</div>
<div className="text-2xl font-bold mt-1">
{keywords.length > 0 ? '1,248' : '1,248'}
{'1,248'}
</div>
</div>
<div className="p-4 rounded-lg bg-emerald-50 border border-emerald-200">
@ -587,125 +574,139 @@ export default function RuleCenterPage() {
{/* Tab 2: 关键词库管理 */}
{activeTab === 'keywords' && (
<div className="space-y-4">
{/* Add Keyword Row */}
<div className="bg-white rounded-xl border border-border-subtle p-6 shadow-sm">
<h3 className="text-base font-semibold mb-4">添加关键词</h3>
<div className="flex items-end gap-3">
<div className="flex-1">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">关键词</span>
<input
type="text"
value={newWord}
onChange={e => setNewWord(e.target.value)}
placeholder="输入关键词"
className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none"
/>
</div>
<div className="w-36">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">所属品类</span>
<select
value={newCategory}
onChange={e => setNewCategory(e.target.value)}
className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none"
>
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div className="w-24">
<span className="text-[10px] font-bold tracking-wider text-on-surface-variant uppercase block mb-1">权重 (1-10)</span>
<input
type="number"
min={1}
max={10}
value={newWeight}
onChange={e => setNewWeight(Math.max(1, Math.min(10, parseInt(e.target.value) || 1)))}
className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none"
/>
</div>
<button
onClick={handleAddKeyword}
disabled={!newWord.trim()}
className="px-4 py-2 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-40 shadow-sm"
>
+ 添加
</button>
</div>
{/* 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>
{/* Keyword Table */}
<div className="bg-white rounded-xl border border-border-subtle shadow-sm overflow-hidden">
<div className="p-4 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">关键词列表</h3>
<div className="flex gap-2">
<button
onClick={() => setShowBatchModal(true)}
className="px-3 py-1.5 bg-white border border-border-subtle text-on-surface-variant rounded-lg text-xs flex items-center gap-1.5 hover:bg-slate-50 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
批量导入
</button>
<button
onClick={() => setShowClearModal(true)}
className="px-3 py-1.5 bg-white border border-border-subtle text-rose-600 rounded-lg text-xs flex items-center gap-1.5 hover:bg-rose-50 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
一键清空
</button>
{/* 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>
<table className="w-full text-left">
<thead className="bg-slate-50 border-b border-border-subtle">
<tr>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">关键词</th>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">所属品类</th>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right">权重</th>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase text-right">匹配次数</th>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">启用状态</th>
<th className="p-4 text-[10px] font-bold tracking-wider text-on-surface-variant uppercase">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-border-subtle">
{keywords.length === 0 ? (
)}
{/* 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>
<td colSpan={6} className="p-8 text-center text-sm text-on-surface-variant">暂无关键词,请添加</td>
<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>
) : (
keywords.map(kw => (
<tr key={kw.id} className="hover:bg-slate-50 transition-colors">
<td className="p-4 text-sm font-medium">{kw.word}</td>
<td className="p-4 text-sm text-on-surface-variant">
<span className="px-2 py-0.5 bg-slate-100 text-on-surface-variant text-[10px] font-medium rounded">{kw.category}</span>
</td>
<td className="p-4 text-sm text-right font-semibold">{kw.weight}</td>
<td className="p-4 text-sm text-right text-on-surface-variant">{kw.matchCount}</td>
<td className="p-4">
<button
onClick={() => toggleKeyword(kw.id)}
className={`relative w-9 h-5 rounded-full transition-colors ${kw.enabled ? 'bg-secondary' : 'bg-slate-300'}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${kw.enabled ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</td>
<td className="p-4">
<div className="flex items-center gap-2">
<button onClick={() => editKeyword(kw.id)} className="text-xs text-blue-600 hover:underline">编辑</button>
<span className="text-slate-300">|</span>
<button onClick={() => deleteKeyword(kw.id)} className="text-xs text-rose-500 hover:underline">删除</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
<div className="bg-slate-50 px-4 py-2 border-t border-border-subtle flex justify-between items-center">
<span className="text-xs text-on-surface-variant">共 {keywords.length} 个关键词</span>
{keywords.length > 0 && (
</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">
启用 {keywords.filter(k => k.enabled).length} 个 · 停用 {keywords.filter(k => !k.enabled).length} 个
手动 {categories.filter(c => !c.isAIGenerated).length} 个 · AI 创建 {categories.filter(c => c.isAIGenerated).length} 个
</span>
)}
</div>
</div>
)}
</div>
</div>
)}
@ -1049,34 +1050,6 @@ export default function RuleCenterPage() {
</div>
)}
{/* Batch Import Modal */}
{showBatchModal && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowBatchModal(false)}>
<div className="bg-white rounded-xl shadow-2xl w-[480px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold">批量导入关键词</h3>
<button onClick={() => setShowBatchModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<p className="text-sm text-on-surface-variant">每行一个关键词,将自动归入默认品类(可后续编辑)</p>
<textarea
value={batchText}
onChange={e => setBatchText(e.target.value)}
placeholder={"防火门\n配电箱\n电线电缆\n阀门\n钢材"}
rows={8}
className="w-full bg-surface-container-low border border-border-subtle rounded-lg px-3 py-2 text-sm focus:ring-secondary outline-none resize-none"
/>
<div className="flex gap-3 pt-2">
<button onClick={handleBatchImport} disabled={!batchText.trim()} className="flex-1 py-2 bg-secondary text-white rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-50">确认导入</button>
<button onClick={() => setShowBatchModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-sm hover:bg-slate-50">取消</button>
</div>
</div>
</div>
</div>
)}
{/* Add Crawler Modal */}
{showAddModal && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowAddModal(false)}>
@ -1177,29 +1150,6 @@ export default function RuleCenterPage() {
</div>
)}
{/* Clear All Modal */}
{showClearModal && (
<div className="fixed inset-0 z-50 bg-black/30 flex items-start justify-center pt-20" onClick={() => setShowClearModal(false)}>
<div className="bg-white rounded-xl shadow-2xl w-[400px] transform" onClick={e => e.stopPropagation()}>
<div className="p-5 border-b border-border-subtle flex justify-between items-center">
<h3 className="text-base font-semibold text-rose-600">确认清空</h3>
<button onClick={() => setShowClearModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="p-5 space-y-4">
<div className="flex items-start gap-3 bg-rose-50 p-3 rounded-lg">
<svg className="w-5 h-5 text-rose-600 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-7v2h2v-2h-2zm0-8v6h2V7h-2z" /></svg>
<p className="text-sm">确定要清空所有关键词吗?此操作不可撤销,共 <b>{keywords.length}</b> 个关键词将被删除。</p>
</div>
<div className="flex gap-3 pt-2">
<button onClick={handleClearAll} className="flex-1 py-2 bg-rose-600 text-white rounded-lg text-sm font-semibold hover:bg-rose-700">确认清空</button>
<button onClick={() => setShowClearModal(false)} className="px-4 py-2 bg-white border border-border-subtle rounded-lg text-sm hover:bg-slate-50">取消</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}