代码修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-27 19:43:15 +08:00
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>
);
}