@@ -0,0 +1,387 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user