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

388 lines
14 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { X, Maximize2, Minimize2, Sparkles, Code2, Loader2 } from "lucide-react"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { Difficulty, KnowledgeItem, KnowledgeItemInput, Mastery } from "@/lib/types"
import {
CATEGORIES,
DIFFICULTIES,
DIFFICULTY_META,
MASTERY_LEVELS,
MASTERY_META,
emptyItemInput,
} from "@/lib/types"
import { useAISettings } from "@/components/ai/ai-settings-provider"
import { requestOpenAICompatible } from "@/lib/ai/client"
import { validateForRequest } from "@/lib/ai/settings"
import { buildCodeGenPrompt, buildOptimizePrompt } from "@/lib/ai/prompts"
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
const CATEGORY_OPTIONS = CATEGORIES.map((c) => ({ value: c, label: c }))
const DIFFICULTY_OPTIONS = DIFFICULTIES.map((d) => ({ value: d, label: DIFFICULTY_META[d].label }))
const MASTERY_OPTIONS = MASTERY_LEVELS.map((m) => ({ value: m, label: MASTERY_META[m].label }))
export function ItemDialog({
open,
onOpenChange,
initial,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
initial: KnowledgeItem | null
onSubmit: (input: KnowledgeItemInput) => void | Promise<void>
}) {
const { settings } = useAISettings()
const [form, setForm] = useState<KnowledgeItemInput>(emptyItemInput())
const [tagInput, setTagInput] = useState("")
const [submitting, setSubmitting] = useState(false)
const [fullscreen, setFullscreen] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const [genCode, setGenCode] = useState(false)
useEffect(() => {
if (!open) return
if (initial) {
setForm({
title: initial.title,
summary: initial.summary ?? "",
content: initial.content ?? "",
code_snippet: initial.code_snippet ?? "",
tags: initial.tags ?? [],
category: initial.category,
difficulty: initial.difficulty,
mastery: initial.mastery,
is_favorite: initial.is_favorite,
source_url: initial.source_url ?? "",
notes: initial.notes ?? "",
})
} else {
setForm(emptyItemInput())
}
setTagInput("")
}, [open, initial])
function update<K extends keyof KnowledgeItemInput>(key: K, value: KnowledgeItemInput[K]) {
setForm((f) => ({ ...f, [key]: value }))
}
function addTag() {
const t = tagInput.trim().replace(/^#/, "")
if (t && !form.tags.includes(t)) update("tags", [...form.tags, t])
setTagInput("")
}
async function submit() {
if (!form.title.trim()) return
setSubmitting(true)
await onSubmit({ ...form, title: form.title.trim() })
setSubmitting(false)
}
async function handleOptimize() {
const err = validateForRequest(settings)
if (err) return toast.error(err)
if (!form.title.trim()) return toast.error("请先填写标题")
setOptimizing(true)
try {
const content = await requestOpenAICompatible({
baseUrl: settings.baseUrl,
apiKey: settings.apiKey,
model: settings.model,
customPrompt: buildOptimizePrompt({ title: form.title, summary: form.summary, content: form.content }),
messages: [],
temperature: settings.temperature,
maxTokens: settings.maxTokens,
})
const cleaned = content.replace(/```(?:json)?/gi, "").trim()
try {
const parsed = JSON.parse(cleaned)
setForm((f) => ({
...f,
summary: typeof parsed.summary === "string" ? parsed.summary : f.summary,
content: typeof parsed.content === "string" ? parsed.content : f.content,
}))
toast.success("已用 AI 优化内容")
} catch {
// not JSON: treat whole thing as content
setForm((f) => ({ ...f, content: cleaned }))
toast.success("已用 AI 优化内容")
}
} catch (e) {
toast.error((e as Error).message)
} finally {
setOptimizing(false)
}
}
async function handleGenerateCode() {
const err = validateForRequest(settings)
if (err) return toast.error(err)
if (!form.title.trim()) return toast.error("请先填写标题")
setGenCode(true)
try {
const content = await requestOpenAICompatible({
baseUrl: settings.baseUrl,
apiKey: settings.apiKey,
model: settings.model,
customPrompt: buildCodeGenPrompt({ title: form.title, summary: form.summary, content: form.content }),
messages: [],
temperature: settings.temperature,
maxTokens: settings.maxTokens,
})
const code = content.replace(/```[a-z]*\n?/gi, "").replace(/```$/g, "").trim()
setForm((f) => ({ ...f, code_snippet: code }))
toast.success("已生成代码示例")
} catch (e) {
toast.error((e as Error).message)
} finally {
setGenCode(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"flex flex-col gap-0 overflow-hidden",
fullscreen
? "left-0 top-0 h-screen max-h-screen w-screen max-w-none translate-x-0 translate-y-0 rounded-none sm:max-w-none"
: "max-h-[90vh] sm:max-w-3xl",
)}
>
<button
type="button"
onClick={() => setFullscreen((v) => !v)}
aria-label={fullscreen ? "退出全屏" : "全屏"}
className="absolute right-12 top-2.5 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
{fullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
</button>
<DialogHeader className="shrink-0 pr-16 pl-2 pb-2">
<DialogTitle>{initial ? "编辑知识点" : "新增知识点"}</DialogTitle>
<DialogDescription>记录前端核心概念、代码片段与个人笔记。</DialogDescription>
</DialogHeader>
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-2">
<div className="grid gap-2">
<Label htmlFor="title">标题 *</Label>
<Input
id="title"
value={form.title}
onChange={(e) => update("title", e.target.value)}
placeholder="例如:事件循环 Event Loop"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="summary">一句话摘要</Label>
<Input
id="summary"
value={form.summary}
onChange={(e) => update("summary", e.target.value)}
placeholder="简短描述这个知识点"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid gap-2">
<Label>分类</Label>
<Select value={form.category} onValueChange={(v) => update("category", v)} items={CATEGORY_OPTIONS}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>难度</Label>
<Select
value={form.difficulty}
onValueChange={(v) => update("difficulty", v as Difficulty)}
items={DIFFICULTY_OPTIONS}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIFFICULTY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>掌握度</Label>
<Select
value={form.mastery}
onValueChange={(v) => update("mastery", v as Mastery)}
items={MASTERY_OPTIONS}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MASTERY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="content">详细内容</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleOptimize}
disabled={optimizing}
className="h-7 gap-1 px-2 text-xs text-primary hover:text-primary"
>
{optimizing ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
AI 优化内容
</Button>
</div>
<Textarea
id="content"
value={form.content}
onChange={(e) => update("content", e.target.value)}
placeholder="详细说明、原理、注意事项..."
className="min-h-24"
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="code">代码片段</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleGenerateCode}
disabled={genCode}
className="h-7 gap-1 px-2 text-xs text-primary hover:text-primary"
>
{genCode ? <Loader2 className="size-3.5 animate-spin" /> : <Code2 className="size-3.5" />}
AI 生成代码示例
</Button>
</div>
<Textarea
id="code"
value={form.code_snippet}
onChange={(e) => update("code_snippet", e.target.value)}
placeholder="// 相关示例代码"
className="min-h-24 font-mono text-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="tags">标签</Label>
<div className="flex gap-2">
<Input
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addTag()
}
}}
placeholder="输入标签后回车"
/>
<Button type="button" variant="outline" onClick={addTag}>
添加
</Button>
</div>
{form.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{form.tags.map((t) => (
<Badge key={t} variant="secondary" className="gap-1 font-mono text-xs">
#{t}
<button onClick={() => update("tags", form.tags.filter((x) => x !== t))}>
<X className="size-3" />
</button>
</Badge>
))}
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="source">来源链接</Label>
<Input
id="source"
value={form.source_url}
onChange={(e) => update("source_url", e.target.value)}
placeholder="https://"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">个人笔记</Label>
<Textarea
id="notes"
value={form.notes}
onChange={(e) => update("notes", e.target.value)}
placeholder="自己的理解、踩坑记录..."
className="min-h-16"
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
<Label htmlFor="fav" className="cursor-pointer">
加入收藏
</Label>
<Switch id="fav" checked={form.is_favorite} onCheckedChange={(v) => update("is_favorite", v)} />
</div>
</div>
<DialogFooter className="shrink-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={submit} disabled={!form.title.trim() || submitting}>
{submitting ? "保存中..." : initial ? "保存修改" : "添加"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}