11<script setup lang="ts">
2- import { ref } from ' vue'
2+ import { ref , computed } from ' vue'
33import { useI18n } from ' vue-i18n'
4+ import axios from ' axios'
45import { apiFetch } from ' @/api'
56import { useToast } from ' @/composables/useToast'
67import { BADGE_CLAWHUB , BADGE_SKILLSSH } from ' @/utils/badges'
@@ -34,7 +35,7 @@ const { show } = useToast()
3435
3536// ── Hub state ────────────────────────────────────────────────────
3637const visible = ref (false )
37- const hubTab = ref <' url' | ' search' >(' search' )
38+ const hubTab = ref <' url' | ' search' | ' zip ' >(' search' )
3839const hubQuery = ref (' ' )
3940const hubResults = ref <HubSkillResult []>([])
4041const hubSearching = ref (false )
@@ -45,6 +46,13 @@ const hubUrlInput = ref('')
4546const hubUrlInstalling = ref (false )
4647const hubUrlOverwrite = ref (false )
4748
49+ // Zip install
50+ const zipFiles = ref <File []>([])
51+ const zipInstalling = ref (false )
52+ const zipOverwrite = ref (false )
53+ const zipResults = ref <{ name: string ; ok: boolean ; msg: string }[]>([])
54+ const zipInstallUrl = computed (() => props .installApiUrl .replace (/ \/ install$ / , ' /install-zip' ))
55+
4856// Install confirm
4957const showInstall = ref (false )
5058const installing = ref (false )
@@ -58,9 +66,44 @@ function open() {
5866 hubSearched .value = false
5967 hubUrlInput .value = ' '
6068 hubUrlOverwrite .value = false
69+ zipFiles .value = []
70+ zipOverwrite .value = false
71+ zipResults .value = []
6172 visible .value = true
6273}
6374
75+ function onZipFilesChange(e : Event ) {
76+ const input = e .target as HTMLInputElement
77+ zipFiles .value = input .files ? Array .from (input .files ) : []
78+ zipResults .value = []
79+ }
80+
81+ async function installZips() {
82+ if (! zipFiles .value .length ) return
83+ zipInstalling .value = true
84+ zipResults .value = []
85+ const ow = zipOverwrite .value ? ' ?overwrite=true' : ' '
86+ let anyOk = false
87+ for (const file of zipFiles .value ) {
88+ try {
89+ const buf = await file .arrayBuffer ()
90+ const res = await axios .post (zipInstallUrl .value + ow , buf , {
91+ headers: { ' Content-Type' : ' application/zip' },
92+ })
93+ const items: { name: string }[] = Array .isArray (res .data ?.data ) ? res .data .data : [res .data ?.data ?? { name: file .name }]
94+ for (const item of items ) {
95+ zipResults .value .push ({ name: item .name , ok: true , msg: ' 安装成功' })
96+ }
97+ anyOk = true
98+ } catch (e : any ) {
99+ const msg = e .response ?.data ?.message || e .response ?.data ?.error || e .message
100+ zipResults .value .push ({ name: file .name , ok: false , msg })
101+ }
102+ }
103+ zipInstalling .value = false
104+ if (anyOk ) emit (' installed' )
105+ }
106+
64107async function hubSearch() {
65108 if (! hubQuery .value .trim ()) return
66109 hubSearching .value = true
@@ -138,7 +181,7 @@ defineExpose({ open })
138181 <!-- Tabs -->
139182 <div style =" display :flex ;border-bottom :1px solid #e2e8f0 ;flex-shrink :0 ;padding :0 20px " >
140183 <button
141- v-for =" tab in ([{key:'search',label:t('skills.search_tab')},{key:'url',label:t('skills.url_install_tab')}] as const)"
184+ v-for =" tab in ([{key:'search',label:t('skills.search_tab')},{key:'url',label:t('skills.url_install_tab')},{key:'zip',label:t('skills.zip_install_tab')} ] as const)"
142185 :key =" tab.key"
143186 @click =" hubTab = tab.key"
144187 style =" padding :10px 16px ;border :none ;background :none ;cursor :pointer ;font-size :13px ;font-weight :500 ;border-bottom :2px solid transparent ;margin-bottom :-1px "
@@ -168,6 +211,40 @@ defineExpose({ open })
168211 </div >
169212 </template >
170213
214+ <!-- Tab: ZIP install -->
215+ <template v-else-if =" hubTab === ' zip' " >
216+ <div style =" display :flex ;gap :8px ;margin-bottom :12px ;align-items :center " >
217+ <input
218+ type =" file"
219+ accept =" .zip"
220+ multiple
221+ @change =" onZipFilesChange"
222+ style =" flex :1 "
223+ />
224+ <button class =" btn-primary" :disabled =" zipInstalling || !zipFiles.length" @click =" installZips" >
225+ {{ zipInstalling ? t('common.loading') : '安装' }}
226+ </button >
227+ </div >
228+ <label style =" display :flex ;align-items :center ;gap :6px ;font-size :13px ;cursor :pointer ;margin-bottom :12px " >
229+ <input type =" checkbox" v-model =" zipOverwrite" /> {{ t('skills.override') }}
230+ </label >
231+ <div v-if =" zipFiles.length && !zipResults.length" style =" font-size :13px ;color :#64748b " >
232+ 已选择 {{ zipFiles.length }} 个文件
233+ </div >
234+ <div v-if =" zipResults.length" style =" margin-top :8px " >
235+ <div
236+ v-for =" (r, i) in zipResults" :key =" i"
237+ style =" padding :8px 12px ;border-radius :6px ;margin-bottom :6px ;font-size :13px "
238+ :style =" r.ok ? 'background:#f0fdf4;color:#166534' : 'background:#fef2f2;color:#991b1b'"
239+ >
240+ <strong >{{ r.name }}</strong >: {{ r.msg }}
241+ </div >
242+ </div >
243+ <div v-if =" !zipFiles.length && !zipResults.length" style =" margin-top :16px ;padding :12px 14px ;background :#f8fafc ;border :1px solid #e2e8f0 ;border-radius :6px ;font-size :12px ;color :#64748b ;line-height :1.7 " >
244+ 选择包含 SKILL.md 的 .zip 文件,支持多选批量安装
245+ </div >
246+ </template >
247+
171248 <!-- Tab: Search -->
172249 <template v-else >
173250 <div style =" display :flex ;gap :8px ;margin-bottom :16px ;flex-shrink :0 " >
0 commit comments