Files
beaver_project/app-instance/frontend/app/(app)/skills/page.tsx
2026-03-13 16:40:08 +08:00

312 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useEffect, useState, useRef } from 'react';
import {
Puzzle,
Upload,
Download,
Trash2,
RefreshCw,
Loader2,
AlertCircle,
X,
} from 'lucide-react';
import { listSkills, deleteSkill, uploadSkill, downloadSkill } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { Skill } from '@/types';
export default function SkillsPage() {
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showUpload, setShowUpload] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const loadSkills = async () => {
setLoading(true);
setError(null);
try {
const data = await listSkills();
setSkills(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || '加载技能失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSkills();
}, []);
const handleDelete = async (name: string) => {
setDeleting(name);
};
const confirmDelete = async (name: string) => {
try {
await deleteSkill(name);
setDeleting(null);
loadSkills();
} catch (err: any) {
setError(err.message || '删除技能失败');
setDeleting(null);
}
};
const handleUploadDone = () => {
setShowUpload(false);
loadSkills();
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Puzzle className="w-6 h-6" />
</h1>
<div className="flex items-center gap-2">
<Button onClick={loadSkills} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
<Button onClick={() => setShowUpload(true)} size="sm">
<Upload className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
{/* Upload Dialog */}
{showUpload && (
<UploadSkillForm
onDone={handleUploadDone}
onCancel={() => setShowUpload(false)}
onError={(msg) => setError(msg)}
/>
)}
{/* Delete Confirmation */}
{deleting && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<p className="text-sm">
<strong>{deleting}</strong>
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleting(null)}
>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => confirmDelete(deleting)}
>
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Skills Table */}
<Card>
<CardContent className="p-0">
{skills.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-1"> zip 使</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{skills.map((skill) => (
<TableRow key={`${skill.source}:${skill.name}`}>
<TableCell className="font-medium">{skill.name}</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground truncate max-w-[300px] block">
{skill.description}
</span>
</TableCell>
<TableCell>
{skill.source === 'builtin' ? (
<Badge variant="secondary" className="text-xs">
</Badge>
) : (
<Badge variant="default" className="text-xs">
</Badge>
)}
</TableCell>
<TableCell>
{skill.available ? (
<Badge variant="default" className="text-xs bg-green-600">
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="下载"
onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))}
>
<Download className="w-3.5 h-3.5" />
</Button>
{skill.source === 'workspace' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(skill.name)}
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function UploadSkillForm({
onDone,
onCancel,
onError,
}: {
onDone: () => void;
onCancel: () => void;
onError: (msg: string) => void;
}) {
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) return;
setUploading(true);
try {
await uploadSkill(file);
onDone();
} catch (err: any) {
onError(err.message || '上传失败');
} finally {
setUploading(false);
}
};
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="skill-zip">
</label>
<input
id="skill-zip"
ref={fileRef}
type="file"
accept=".zip"
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
/>
<p className="text-xs text-muted-foreground">
`SKILL.md`
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<Button type="submit" disabled={uploading}>
{uploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}