import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfig } from '@/state/config'
import { ipc } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import { qk } from '@/lib/queryKeys'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ErrorBanner } from '@/components/error-banner'
import { AlertTriangle, Archive, CheckCircle2, Loader2 } from 'lucide-react'
export function LintPage() {
const cfg = useConfig()
const qc = useQueryClient()
const configPath = cfg.configPath
const lintQuery = useQuery({
queryKey: qk.wikiLint(configPath),
queryFn: () => ipc.wikiLint({ config_path: configPath }),
enabled: configPath.trim().length > 0,
})
const archiveAllMutation = useMutation({
mutationFn: async (suggestionIds: string[]) => {
for (const recordId of suggestionIds) {
await ipc.applyMemoryAction({
config_path: configPath,
record_id: recordId,
action: 'archive',
metadata: {
actor: 'lint-cleanup',
reason: 'archive-all',
evidence_refs: [],
},
})
}
return suggestionIds
},
onSuccess: (ids) => {
qc.invalidateQueries({ queryKey: qk.wikiLint(configPath) })
qc.invalidateQueries({ queryKey: qk.workbench(configPath) })
for (const id of ids) {
qc.invalidateQueries({ queryKey: qk.record(configPath, id) })
qc.invalidateQueries({ queryKey: qk.history(configPath, id) })
}
},
})
const result = lintQuery.data ?? null
const error = lintQuery.error ?? archiveAllMutation.error ?? null
const errorEnvelope = error ? asEnvelope(error) : null
const loading = lintQuery.isFetching
const archiving = archiveAllMutation.isPending
function archiveAll() {
const suggestions = result?.report?.prune_suggestions ?? []
if (suggestions.length === 0) return
archiveAllMutation.mutate(suggestions.map((s) => s.record_id))
}
const report = result?.report
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
知识库健康检查
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</h2>
</div>
{errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}
{report && (
<>
<div className="grid gap-3 sm:grid-cols-4">
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold">{report.total_active_records}</p>
<p className="text-xs text-muted-foreground">活跃记录</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold">{report.prune_suggestions.length}</p>
<p className="text-xs text-muted-foreground">可归档</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold">{report.broken_cross_refs.length}</p>
<p className="text-xs text-muted-foreground">断链</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold">{report.orphan_notes.length}</p>
<p className="text-xs text-muted-foreground">孤儿 note</p>
</CardContent>
</Card>
</div>
{report.prune_suggestions.length === 0 &&
report.broken_cross_refs.length === 0 &&
report.orphan_notes.length === 0 ? (
<Card>
<CardContent className="flex items-center gap-2 p-4">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span>知识库干净,无需清理。</span>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{report.prune_suggestions.length > 0 && (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-1">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
可归档 ({report.prune_suggestions.length})
</CardTitle>
<Button
size="sm"
variant="destructive"
className="h-7 text-xs"
disabled={archiving}
onClick={archiveAll}
>
{archiving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Archive className="mr-1 h-3.5 w-3.5" />}
全部归档
</Button>
</div>
</CardHeader>
<CardContent className="max-h-[300px] overflow-y-auto space-y-1.5 text-sm">
{report.prune_suggestions.map((s) => (
<div key={s.record_id} className="flex items-center gap-2 rounded border border-muted px-2 py-1.5">
<Badge variant="outline" className="shrink-0 text-[10px]">{s.reason.kind}</Badge>
<span className="truncate text-xs">{s.title}</span>
</div>
))}
</CardContent>
</Card>
)}
{report.broken_cross_refs.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-1">
<AlertTriangle className="h-4 w-4 text-red-500" />
断链 ({report.broken_cross_refs.length})
</CardTitle>
</CardHeader>
<CardContent className="max-h-[300px] overflow-y-auto space-y-1.5 text-sm">
{report.broken_cross_refs.map((b, i) => (
<div key={i} className="rounded border border-muted px-2 py-1.5">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium">{b.title}</span>
<Badge variant="secondary" className="shrink-0 text-[10px]">{b.field}</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
→ <span className="text-red-500">{b.missing_target}</span>
</p>
</div>
))}
</CardContent>
</Card>
)}
{report.orphan_notes.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-1">
<AlertTriangle className="h-4 w-4 text-orange-500" />
孤儿 note ({report.orphan_notes.length})
</CardTitle>
</CardHeader>
<CardContent className="max-h-[300px] overflow-y-auto space-y-1.5 text-sm">
{report.orphan_notes.map((o) => (
<div key={o.record_id} className="rounded border border-muted px-2 py-1.5">
<p className="truncate text-xs font-mono text-muted-foreground" title={o.record_id}>
{o.record_id}
</p>
<p className="truncate text-xs text-muted-foreground/60" title={o.relative_path}>
{o.relative_path.split('/').pop()}
</p>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
</>
)}
</div>
)
}