388 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|