201 lines
8.0 KiB
TypeScript
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>
|
|
)
|
|
}
|