diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index d7b0f2d6..aea2f112 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -994,6 +994,8 @@ "invalidSkillUrlSource": "Enter a valid HTTP(S) skill URL; supported sources are validated by the server adapter", "zipHintTitle": "ZIP layout (each top-level folder = one skill):", "zipHintDetail": "Each folder must contain SKILL.md and may include scripts or other files. Only .zip is supported.", + "zipDragDropHint": "Drag and drop your files here to upload", + "zipSelected": "Selected: {{name}}", "chooseZip": "Choose ZIP file", "noZipSelected": "No file selected", "removeZip": "Remove", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 6b411fe3..a6fab45a 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -992,6 +992,8 @@ "invalidSkillUrlSource": "请输入有效的 HTTP(S) 技能 URL;具体来源由服务端适配器校验", "zipHintTitle": "压缩包结构(每个一级文件夹 = 一个技能):", "zipHintDetail": "每个文件夹内必须包含 SKILL.md,可附带脚本等其他文件。仅支持 .zip。", + "zipDragDropHint": "将需要上传的文件拖拽到此处", + "zipSelected": "已选择: {{name}}", "chooseZip": "选择 ZIP 文件", "noZipSelected": "未选择文件", "removeZip": "移除", diff --git a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.test.tsx b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.test.tsx index 43faa0bd..cf8f6beb 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.test.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.test.tsx @@ -186,7 +186,9 @@ describe(" local zip import", () => { 'input[type="file"]', ) as HTMLInputElement; setInputFiles(fileInput, [zipFile]); - await user.click(screen.getByText("skills.removeZip")); + const removeButtons = screen.getAllByText("skills.removeZip"); + // Click either remove button + await user.click(removeButtons[0]); expect( screen.getByRole("button", { name: "skills.importSkills" }), ).toBeDisabled(); diff --git a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx index 28fb2265..c19a253d 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillImportModal.tsx @@ -59,6 +59,7 @@ export function SkillImportModal({ const [zipError, setZipError] = useState(""); const [overwrite, setOverwrite] = useState(false); const [parsing, setParsing] = useState(false); + const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); useEffect(() => { @@ -70,9 +71,24 @@ export function SkillImportModal({ setZipError(""); setOverwrite(false); setParsing(false); + setIsDragging(false); } }, [open]); + const handleFileSelect = (file: File | null) => { + if (!file) { + setZipFile(null); + return; + } + if (!file.name.toLowerCase().endsWith(".zip")) { + setZipFile(null); + setZipError(t("skills.zipOnly")); + return; + } + setZipError(""); + setZipFile(file); + }; + const handleUrlChange = (value: string) => { setImportUrl(value); const trimmed = value.trim(); @@ -133,6 +149,28 @@ export function SkillImportModal({ ? !importUrl.trim() || !!importUrlError : !zipFile || !!zipError); + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + if (busy) return; + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (busy) return; + + const files = e.dataTransfer.files; + if (files && files.length > 0) { + handleFileSelect(files[0]); + } + }; + return ( + !busy && fileInputRef.current?.click()} + > + + + + + + + + + {zipFile ? t("skills.zipSelected", { name: zipFile.name }) : t("skills.zipDragDropHint")} + + {zipFile ? ( + { + e.stopPropagation(); + setZipFile(null); + setZipError(""); + }} + > + {t("skills.removeZip")} + + ) : null} + + { const next = event.target.files?.[0] ?? null; event.target.value = ""; - setZipError(""); - if (!next) { - setZipFile(null); - return; - } - if (!next.name.toLowerCase().endsWith(".zip")) { - setZipFile(null); - setZipError(t("skills.zipOnly")); - return; - } - setZipFile(next); + handleFileSelect(next); }} /> + } @@ -268,6 +329,7 @@ export function SkillImportModal({ ) : null} + {zipError ? ( {zipError} ) : null} diff --git a/dashboard/src/pages/Agent/Skills/index.module.less b/dashboard/src/pages/Agent/Skills/index.module.less index c735c6d5..b660d39b 100644 --- a/dashboard/src/pages/Agent/Skills/index.module.less +++ b/dashboard/src/pages/Agent/Skills/index.module.less @@ -965,6 +965,7 @@ align-items: center; gap: 12px; flex-wrap: wrap; + margin-top: 16px; } .zipFileName { @@ -972,3 +973,47 @@ font-size: 13px; word-break: break-all; } + +.zipDragDrop { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 32px 24px; + border: 2px dashed var(--fn-border-input); + border-radius: var(--fn-radius-md); + cursor: pointer; + transition: all 0.2s ease; + background-color: var(--fn-bg-tertiary); + + &:hover { + border-color: var(--fn-color-brand); + background-color: color-mix(in srgb, var(--fn-color-brand) 5%, var(--fn-bg-tertiary)); + } +} + +.zipDragDropActive { + border-color: var(--fn-color-brand); + background-color: color-mix(in srgb, var(--fn-color-brand) 10%, var(--fn-bg-tertiary)); + transform: scale(1.01); +} + +.zipDragDropHasFile { + border-style: solid; + border-color: var(--fn-color-brand); +} + +.zipDragDropIcon { + color: var(--fn-text-tertiary); + opacity: 0.8; + display: flex; + align-items: center; + justify-content: center; +} + +.zipDragDropText { + color: var(--fn-text-secondary); + font-size: 14px; + text-align: center; +}