- 后端将技能展示适配配置存入数据库,引用批次按持久化映射进行适配 - 前端新增技能文件预览弹窗与文件树组件,完善技能来源展示 - 修正 .gitignore 中宽泛的 skills/ 规则误忽略前端组件目录的问题 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
153 lines
4.1 KiB
TypeScript
153 lines
4.1 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ChevronRightIcon,
|
|
FileCode2Icon,
|
|
FileIcon,
|
|
FileJsonIcon,
|
|
FileTextIcon,
|
|
FolderIcon,
|
|
FolderOpenIcon,
|
|
ImageIcon,
|
|
} from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import type { SkillFileNode } from "@/core/skills/api";
|
|
|
|
const CODE_EXTS = new Set([
|
|
"py", "js", "jsx", "ts", "tsx", "mjs", "cjs",
|
|
"sh", "bash", "zsh", "ps1",
|
|
"html", "htm", "css", "scss", "sass", "xml", "svg",
|
|
"sql", "ini", "toml", "cfg", "env", "yaml", "yml",
|
|
]);
|
|
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff"]);
|
|
|
|
function fileIconFor(name: string) {
|
|
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
|
|
if (name === "SKILL.md") return FileTextIcon;
|
|
if (IMAGE_EXTS.has(ext)) return ImageIcon;
|
|
if (ext === "md" || ext === "markdown" || ext === "txt" || ext === "rst") return FileTextIcon;
|
|
if (ext === "json" || ext === "jsonl") return FileJsonIcon;
|
|
if (CODE_EXTS.has(ext)) return FileCode2Icon;
|
|
return FileIcon;
|
|
}
|
|
|
|
function TreeRow({
|
|
node,
|
|
depth,
|
|
selectedPath,
|
|
onOpen,
|
|
}: {
|
|
node: SkillFileNode;
|
|
depth: number;
|
|
selectedPath: string | null;
|
|
onOpen: (path: string) => void;
|
|
}) {
|
|
// Top-level (root) auto-expands; nested directories start collapsed.
|
|
const [open, setOpen] = useState(depth === 0);
|
|
|
|
if (node.type === "dir") {
|
|
const FolderGlyph = open ? FolderOpenIcon : FolderIcon;
|
|
return (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((value) => !value)}
|
|
className={cn(
|
|
"group hover:bg-muted/60 flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-sm",
|
|
)}
|
|
style={{ paddingLeft: 6 + depth * 14 }}
|
|
>
|
|
<ChevronRightIcon
|
|
className={cn(
|
|
"text-muted-foreground size-3.5 shrink-0 transition-transform",
|
|
open && "rotate-90",
|
|
)}
|
|
/>
|
|
<FolderGlyph className="size-4 shrink-0 text-amber-500/90" />
|
|
<span className="truncate font-medium">{node.name}</span>
|
|
</button>
|
|
{open && node.children && node.children.length > 0 && (
|
|
<div className="border-border/60 ml-[14px] border-l border-dashed">
|
|
{node.children.map((child) => (
|
|
<TreeRow
|
|
key={child.path || child.name}
|
|
node={child}
|
|
depth={depth + 1}
|
|
selectedPath={selectedPath}
|
|
onOpen={onOpen}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const Icon = fileIconFor(node.name);
|
|
const isSelected = selectedPath === node.path;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen(node.path)}
|
|
className={cn(
|
|
"hover:bg-muted/60 flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-sm",
|
|
isSelected && "bg-primary/10 hover:bg-primary/15",
|
|
)}
|
|
style={{ paddingLeft: 6 + depth * 14 + 14 }}
|
|
title={node.path}
|
|
>
|
|
<Icon className="text-muted-foreground size-4 shrink-0" />
|
|
<span className="truncate">{node.name}</span>
|
|
{typeof node.size === "number" && (
|
|
<span className="text-muted-foreground/70 ml-auto shrink-0 text-[10px] tabular-nums">
|
|
{formatBytes(node.size)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function formatBytes(n: number): string {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
export function SkillFileTree({
|
|
root,
|
|
onOpen,
|
|
selectedPath,
|
|
emptyText,
|
|
}: {
|
|
root: SkillFileNode;
|
|
onOpen: (path: string) => void;
|
|
selectedPath: string | null;
|
|
emptyText: string;
|
|
}) {
|
|
const isEmpty = useMemo(
|
|
() => !root.children || root.children.length === 0,
|
|
[root],
|
|
);
|
|
|
|
if (isEmpty) {
|
|
return (
|
|
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm">
|
|
{emptyText}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="font-mono">
|
|
<TreeRow
|
|
node={root}
|
|
depth={0}
|
|
selectedPath={selectedPath}
|
|
onOpen={onOpen}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|