Files
Next_Knowledge_Base/components/ai/ai-result-preview.tsx
T
jiawei 73b9c9fb7a
knowledge-base / deploy (push) Failing after 11s
feat: 初版
2026-06-23 19:09:11 +08:00

201 lines
8.0 KiB
TypeScript

"use client"
import { useState } from "react"
import { ChevronDown, ChevronRight, Copy, Download } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { DIFFICULTIES, DIFFICULTY_META } from "@/lib/types"
import { AI_CATEGORY_ENUM, type AIDraftItem } from "@/lib/ai/types"
export type DraftRow = AIDraftItem & { _id: string; _selected: boolean }
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
const CATEGORY_OPTIONS = AI_CATEGORY_ENUM.map((c) => ({ value: c, label: c }))
const DIFFICULTY_OPTIONS = DIFFICULTIES.map((d) => ({ value: d, label: DIFFICULTY_META[d].label }))
export function AIResultPreview({
rows,
onChange,
}: {
rows: DraftRow[]
onChange: (rows: DraftRow[]) => void
}) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
function patch(id: string, patch: Partial<DraftRow>) {
onChange(rows.map((r) => (r._id === id ? { ...r, ...patch } : r)))
}
function toggleAll(value: boolean) {
onChange(rows.map((r) => ({ ...r, _selected: value })))
}
function copyJson() {
const items = rows.map(({ _id, _selected, ...rest }) => rest)
navigator.clipboard.writeText(JSON.stringify({ items }, null, 2))
toast.success("已复制 JSON")
}
function exportJson() {
const items = rows.map(({ _id, _selected, ...rest }) => rest)
const blob = new Blob([JSON.stringify({ items }, null, 2)], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `knowledge-items-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
}
const allSelected = rows.length > 0 && rows.every((r) => r._selected)
const selectedCount = rows.filter((r) => r._selected).length
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(!!v)} />
全选 · 已选 {selectedCount}/{rows.length}
</label>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={copyJson} className="gap-1.5">
<Copy className="size-3.5" />
复制 JSON
</Button>
<Button variant="outline" size="sm" onClick={exportJson} className="gap-1.5">
<Download className="size-3.5" />
导出
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
{rows.map((row) => {
const open = expanded[row._id]
return (
<div
key={row._id}
className={cn(
"rounded-lg border bg-card/60 transition-colors",
row._selected ? "border-primary/40" : "border-border",
)}
>
<div className="flex items-center gap-2 p-3">
<Checkbox checked={row._selected} onCheckedChange={(v) => patch(row._id, { _selected: !!v })} />
<Input
value={row.title}
onChange={(e) => patch(row._id, { title: e.target.value })}
className="h-8 flex-1 bg-input/60 text-sm font-medium"
/>
<Badge variant="outline" className="hidden font-mono text-[10px] sm:inline-flex">
{row.category}
</Badge>
<button
type="button"
onClick={() => setExpanded((e) => ({ ...e, [row._id]: !e[row._id] }))}
className="text-muted-foreground hover:text-foreground"
aria-label={open ? "收起" : "展开"}
>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button>
</div>
{open && (
<div className="grid gap-3 border-t border-border p-3">
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground">摘要</span>
<Input
value={row.summary}
onChange={(e) => patch(row._id, { summary: e.target.value })}
className="bg-input/60 text-sm"
/>
</div>
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground">正文</span>
<Textarea
value={row.content}
onChange={(e) => patch(row._id, { content: e.target.value })}
className="min-h-20 bg-input/60 text-sm"
/>
</div>
{row.code_snippet && (
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground">代码片段</span>
<Textarea
value={row.code_snippet}
onChange={(e) => patch(row._id, { code_snippet: e.target.value })}
className="min-h-20 bg-input/60 font-mono text-sm"
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground">分类</span>
<Select
value={row.category}
onValueChange={(v) => patch(row._id, { category: v })}
items={CATEGORY_OPTIONS}
>
<SelectTrigger className="h-8 bg-input/60 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<span className="font-mono text-xs text-muted-foreground">难度</span>
<Select
value={row.difficulty}
onValueChange={(v) => patch(row._id, { difficulty: v as AIDraftItem["difficulty"] })}
items={DIFFICULTY_OPTIONS}
>
<SelectTrigger className="h-8 bg-input/60 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIFFICULTY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{row.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{row.tags.map((t) => (
<Badge key={t} variant="secondary" className="font-mono text-xs">
#{t}
</Badge>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}