diff --git a/app/skills/components/SkillImportDialog.tsx b/app/skills/components/SkillImportDialog.tsx new file mode 100644 index 0000000..6258808 --- /dev/null +++ b/app/skills/components/SkillImportDialog.tsx @@ -0,0 +1,310 @@ +"use client"; + +import React, { useCallback, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + AlertCircle, ChevronDown, ChevronRight, File as FileIcon, Folder, + FolderOpen, Loader2, Upload, +} from "lucide-react"; +import { toast } from "sonner"; +import axios from "@/lib/axios"; +import { + isZipFile, precheck, readDataTransfer, readFileList, readZip, toPayload, + type BundleFile, +} from "@/lib/skillBundle"; +import { + buildTree, countByStatus, formatSize, + type FileStatus, type ImportFileVerdict, type TreeNode, +} from "@/lib/skillImportTree"; + +interface ValidateResult { + ok: boolean; + name: string; + description: string; + stripped_root: string | null; + errors: string[]; + warnings: string[]; + files: ImportFileVerdict[]; + total_files: number; + total_bytes: number; +} + +const STATUS_STYLE: Record = { + error: "text-destructive", + warning: "text-warning", + ok: "text-foreground", + // 跳过的文件压暗但**仍然列出来** —— 不显示会让用户以为文件传丢了 + skipped: "text-muted-foreground/60 line-through", +}; + +export default function SkillImportDialog({ + open, onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const t = useTranslations("skills"); + const tc = useTranslations("common"); + const router = useRouter(); + + const [busy, setBusy] = useState(null); + const [files, setFiles] = useState([]); + const [result, setResult] = useState(null); + const [dragging, setDragging] = useState(false); + const [collapsed, setCollapsed] = useState>(new Set()); + + const folderInput = useRef(null); + const zipInput = useRef(null); + + const reset = () => { + setFiles([]); setResult(null); setBusy(null); setCollapsed(new Set()); + }; + + const validate = useCallback(async (bundle: BundleFile[]) => { + const localError = precheck(bundle); + if (localError) { toast.error(localError); return; } + + setFiles(bundle); + setBusy("validating"); + try { + const { data } = await axios.post( + "/api/v1/skills/import/validate", toPayload(bundle)); + setResult(data); + } catch (e: any) { + toast.error(e?.response?.data?.detail || t("importFailed")); + setResult(null); + } finally { + setBusy(null); + } + }, [t]); + + const ingest = useCallback(async (read: () => Promise) => { + setBusy("reading"); + try { + await validate(await read()); + } catch (e: any) { + toast.error(e?.message || t("importFailed")); + setBusy(null); + } + }, [validate, t]); + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + void ingest(() => readDataTransfer(e.dataTransfer)); + }; + + const doImport = async () => { + if (!result?.ok || files.length === 0) return; + setBusy("importing"); + try { + const { data } = await axios.post("/api/v1/skills/import", toPayload(files)); + toast.success(t("importSuccess")); + onOpenChange(false); + reset(); + // 直接进编辑页:显示名留空,用户下一步就是填它 + if (data?.id) router.push(`/skills/${data.id}`); + } catch (e: any) { + const detail = e?.response?.data?.detail; + toast.error(typeof detail === "string" ? detail + : detail?.message || t("importFailed")); + // 失败时重新校验一次:可能是这两次请求之间有人占了同名 + if (files.length) void validate(files); + } finally { + setBusy(null); + } + }; + + const counts = result ? countByStatus(result.files) : null; + const errorCount = (result?.errors.length ?? 0) + (counts?.error ?? 0); + + const toggle = (path: string) => setCollapsed((prev) => { + const next = new Set(prev); + next.has(path) ? next.delete(path) : next.add(path); + return next; + }); + + const renderNode = (node: TreeNode, depth = 0): React.ReactNode => { + const isCollapsed = collapsed.has(node.path); + return ( +
+
+ {node.isDir ? ( + + ) : ( + + )} + +
+
+ {node.name} + {!node.isDir && node.kind && ( + {node.kind} + )} + + {formatSize(node.size)} + +
+ {/* 原因紧跟在文件下方——把它收进 tooltip 的话,用户得逐个悬停才知道哪里错 */} + {!node.isDir && node.reason && ( +
+ {node.reason} +
+ )} +
+
+ {node.isDir && !isCollapsed && node.children.map((c) => renderNode(c, depth + 1))} +
+ ); + }; + + return ( + { onOpenChange(v); if (!v) reset(); }}> + + + {t("importTitle")} + {t("importHint")} + + + {!result && ( +
{ e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + className={`border-2 border-dashed rounded-lg p-10 text-center transition-colors ${ + dragging ? "border-primary bg-primary/5" : "border-border"}`} + > + {busy ? ( +
+ + {busy === "reading" ? t("importReading") : t("importValidating")} +
+ ) : ( + <> + +

{t("importHint")}

+
+ + +
+ + )} +
+ )} + + {/* webkitdirectory 不是标准属性,React 需要用 ref 之外的方式注入 */} + { + const fl = e.target.files; + if (fl?.length) void ingest(() => readFileList(fl)); + e.target.value = ""; + }} + /> + { + const f = e.target.files?.[0]; + if (f) { + if (!isZipFile(f)) { toast.error(t("importPickZip")); return; } + void ingest(() => readZip(f)); + } + e.target.value = ""; + }} + /> + + {result && ( +
+ {result.stripped_root && ( +
+ {t("importStrippedRoot", { root: result.stripped_root })} +
+ )} + + {result.name && ( +
+
name:{" "} + {result.name}
+
+ {t("importDisplayNameTip")} +
+
+ )} + + {errorCount > 0 && ( +
+
+ + {t("importHasErrors", { n: errorCount })} +
+ {result.errors.length > 0 && ( +
    + {result.errors.map((e, i) =>
  • {e}
  • )} +
+ )} +
+ )} + + {result.warnings.map((w, i) => ( +
⚠️ {w}
+ ))} + + {counts && ( +
+ {t("importSummary", { ok: counts.ok + counts.warning, skipped: counts.skipped })} +
+ )} + +
+ {buildTree(result.files).map((n) => renderNode(n))} +
+
+ )} + + + + {result && ( + <> + + + + )} + +
+
+ ); +} diff --git a/app/skills/page.tsx b/app/skills/page.tsx index e7e97b3..05f53c0 100644 --- a/app/skills/page.tsx +++ b/app/skills/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import SkillImportDialog from "./components/SkillImportDialog"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { @@ -23,7 +24,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Loader2, Plus, Search, Sparkles } from "lucide-react"; +import { Loader2, Plus, Search, Sparkles, Upload } from "lucide-react"; import { useDebounce } from "use-debounce"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useSkills } from "@/hooks/useSkills"; @@ -58,6 +59,7 @@ export default function SkillsPage() { // 删除被引用时(409)弹引用应用清单 const [referencedApps, setReferencedApps] = useState(null); const [deletingSkill, setDeletingSkill] = useState(null); + const [importOpen, setImportOpen] = useState(false); const handleDelete = async (skill: Skill) => { if (!confirm(t("deleteConfirm", { name: skill.name }))) return; @@ -89,6 +91,11 @@ export default function SkillsPage() { className="pl-8 w-56" /> + {/* 导入放在新建左边:从别处搬一个现成 skill 进来,比从空白开始更常见 */} +