代码修改
This commit is contained in:
1 parent
8f5af46ec2
commit
8f75e12871
168 files changed
+76575
No files matched your search
@@ -0,0 +1,770 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FileCode2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
interface FileSkill {
|
||||
id: string;
|
||||
folder: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: 'md' | 'py';
|
||||
filename: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://ch1.b.uat.4.cn/zncmback/api/skills/files';
|
||||
|
||||
export default function FileSkillsWorkshop() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const [skills, setSkills] = useState<FileSkill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FileSkill | null>(null);
|
||||
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
||||
|
||||
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(API_BASE);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const data = (await res.json()) as FileSkill[];
|
||||
setSkills(data);
|
||||
} catch (err) {
|
||||
console.error('加载技能失败', err);
|
||||
setError('加载技能失败,请稍后重试');
|
||||
setSkills([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
||||
setOpenCardMenuId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase();
|
||||
if (!keyword) return skills;
|
||||
return skills.filter((s) =>
|
||||
`${s.name} ${s.description} ${s.filename}`.toLowerCase().includes(keyword),
|
||||
);
|
||||
}, [searchTerm, skills]);
|
||||
|
||||
const handleDelete = async (skill: FileSkill) => {
|
||||
setOpenCardMenuId(null);
|
||||
if (!window.confirm(`确认删除技能「${skill.name}」?`)) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
setSkills((prev) => prev.filter((s) => s.folder !== skill.folder));
|
||||
} catch (err) {
|
||||
console.error('删除技能失败', err);
|
||||
setError('删除技能失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-[calc(100vh-140px)] ${
|
||||
isDark ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'
|
||||
}`}
|
||||
>
|
||||
{createOpen && (
|
||||
<CreateSkillModal
|
||||
isDark={isDark}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => {
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditSkillModal
|
||||
isDark={isDark}
|
||||
skill={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
||||
<section className="mb-7 flex items-start justify-between gap-5">
|
||||
<div>
|
||||
<h1
|
||||
className={`text-[22px] font-bold tracking-tight ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
>
|
||||
技能管理
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
上传 Markdown 提示词或 Python 脚本作为技能,供 MCP 与模型调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建技能
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`mb-8 rounded-[28px] border px-6 py-5 ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 ${
|
||||
isDark ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索技能名称 / 描述..."
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${
|
||||
isDark
|
||||
? 'text-slate-100 placeholder:text-slate-500'
|
||||
: 'text-gray-800 placeholder:text-[#9ca3af]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{loading ? (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/60 text-slate-300'
|
||||
: 'border-[#f5d4d4] bg-white text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
正在加载技能...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
加载失败
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
|
||||
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${
|
||||
isDark ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h3
|
||||
className={`mb-2 text-lg font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
暂无技能
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
|
||||
点击右上角「新建技能」上传你的第一个技能。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((skill) => {
|
||||
const isPy = skill.kind === 'py';
|
||||
const Icon = isPy ? FileCode2 : FileText;
|
||||
return (
|
||||
<article
|
||||
key={skill.folder}
|
||||
className={`group relative overflow-hidden rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
||||
isDark
|
||||
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
||||
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 h-1 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute right-4 top-4 z-20"
|
||||
ref={openCardMenuId === skill.folder ? cardMenuRef : null}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId((prev) =>
|
||||
prev === skill.folder ? null : skill.folder,
|
||||
);
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{openCardMenuId === skill.folder && (
|
||||
<div
|
||||
className={`absolute right-0 top-[calc(100%+6px)] z-30 w-36 rounded-2xl border py-1 shadow-xl ${
|
||||
isDark ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpenCardMenuId(null);
|
||||
setEditing(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-300 hover:bg-slate-800'
|
||||
: 'text-gray-700 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(skill);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-rose-400 hover:bg-slate-800'
|
||||
: 'text-rose-500 hover:bg-[#fff6f6]'
|
||||
}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div
|
||||
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ring-1 ${
|
||||
isPy
|
||||
? 'from-indigo-100 via-blue-50 to-white text-indigo-500 shadow-indigo-100/80 ring-indigo-100'
|
||||
: 'from-rose-100 via-red-50 to-white text-red-500 shadow-red-100/80 ring-red-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="relative z-10 h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className={`truncate text-[17px] font-semibold ${
|
||||
isDark ? 'text-slate-100' : 'text-[#111827]'
|
||||
}`}
|
||||
title={skill.name}
|
||||
>
|
||||
{skill.name}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-1 line-clamp-3 min-h-[3.6em] text-sm ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-4 flex items-center justify-between text-xs ${
|
||||
isDark ? 'text-slate-500' : 'text-[#98a2b3]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium ${
|
||||
isPy
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'bg-[#fff2f2] text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
{isPy ? 'Python' : 'Markdown'}
|
||||
</span>
|
||||
<span>{new Date(skill.updated_at * 1000).toLocaleString()}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateModalProps {
|
||||
isDark: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function CreateSkillModal({ isDark, onClose, onCreated }: CreateModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleFileChange = (next: File | null) => {
|
||||
setFile(next);
|
||||
if (next && !name.trim()) {
|
||||
const stem = next.name.replace(/\.[^.]+$/, '');
|
||||
setName(stem);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file) {
|
||||
setErrMsg('请上传 .md 或 .py 文件');
|
||||
return;
|
||||
}
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (name.trim()) fd.append('name', name.trim());
|
||||
if (description.trim()) fd.append('description', description.trim());
|
||||
const res = await fetch(API_BASE, { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('新建技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '新建技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-100'
|
||||
: 'border-[#f3d6d6] bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1 w-full ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-6 pb-3 pt-5">
|
||||
<h2 className="text-base font-semibold">新建技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能文件(.md 或 .py)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => handleFileChange(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : '选择文件'}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
支持 .md 提示词 或 .py 可执行脚本
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="默认使用上传文件名"
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述(可选)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="技能用途、输入输出说明等"
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errMsg && (
|
||||
<p className="text-xs text-rose-500">{errMsg}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '提交中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditModalProps {
|
||||
isDark: boolean;
|
||||
skill: FileSkill;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function EditSkillModal({ isDark, skill, onClose, onSaved }: EditModalProps) {
|
||||
const [name, setName] = useState(skill.name);
|
||||
const [description, setDescription] = useState(skill.description ?? '');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errMsg, setErrMsg] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setErrMsg('');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (file) fd.append('file', file);
|
||||
if (name.trim() !== skill.name) fd.append('name', name.trim());
|
||||
if (description !== (skill.description ?? '')) fd.append('description', description);
|
||||
|
||||
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
|
||||
method: 'PUT',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
console.error('保存技能失败', err);
|
||||
setErrMsg(err instanceof Error ? err.message : '保存技能失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-100'
|
||||
: 'border-[#f3d6d6] bg-white text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-1 w-full ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
|
||||
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between px-6 pb-3 pt-5">
|
||||
<h2 className="text-base font-semibold">编辑技能</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
||||
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 pb-2 pt-2">
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
技能名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
|
||||
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`mb-1 block text-xs font-medium ${
|
||||
isDark ? 'text-slate-400' : 'text-[#667085]'
|
||||
}`}
|
||||
>
|
||||
覆盖文件(可选,选择后将替换原有内容)
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".md,.py"
|
||||
className="hidden"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
|
||||
isDark
|
||||
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
|
||||
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
|
||||
<Upload className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`truncate text-sm font-medium ${
|
||||
isDark ? 'text-slate-100' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{file ? file.name : `当前文件:${skill.filename}`}
|
||||
</p>
|
||||
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
仅支持 .md 或 .py
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errMsg && <p className="text-xs text-rose-500">{errMsg}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`rounded-xl px-4 py-2 text-sm transition ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
|
||||
}`}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
|
||||
submitting ? 'cursor-not-allowed opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user