771 lines
28 KiB
TypeScript
771 lines
28 KiB
TypeScript
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>
|
||
);
|
||
}
|