fix: profile import file upload, startup health check, sidebar scroll, node-pty fallback

- Change profile import from server path input to browser file upload (multipart)
- Fix startup script to wait for health check before opening browser
- Add overflow scroll with hidden scrollbar to sidebar nav
- Graceful degradation when node-pty fails to load (WSL compatibility)
- Remove rename button from profile cards
- Restrict profile name input to English letters, numbers, hyphens
- Use raw.githubusercontent.com URLs in README setup script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-16 15:19:05 +08:00
parent 99a47cf1ad
commit 26423984d1
14 changed files with 192 additions and 67 deletions
+13 -4
View File
@@ -100,13 +100,22 @@ export async function exportProfile(name: string): Promise<boolean> {
}
}
export async function importProfile(archive: string, name?: string): Promise<boolean> {
export async function importProfile(file: File): Promise<boolean> {
try {
await request('/api/hermes/profiles/import', {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`${baseUrl}/api/hermes/profiles/import`, {
method: 'POST',
body: JSON.stringify({ archive, name }),
headers,
body: formData,
})
return true
return res.ok
} catch {
return false
}
@@ -6,7 +6,7 @@ import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
const props = defineProps<{ profile: HermesProfile }>()
const emit = defineEmits<{ rename: [name: string] }>()
const emit = defineEmits<{}>()
const { t } = useI18n()
const profilesStore = useProfilesStore()
@@ -154,9 +154,6 @@ async function handleExport() {
>
{{ t('profiles.switchTo') }}
</NButton>
<NButton size="tiny" quaternary @click="emit('rename', profile.name)">
{{ t('profiles.rename') }}
</NButton>
<NButton
size="tiny"
quaternary
@@ -56,8 +56,9 @@ function handleClose() {
<NForm label-placement="top">
<NFormItem :label="t('profiles.name')" required>
<NInput
v-model:value="name"
:value="name"
:placeholder="t('profiles.namePlaceholder')"
@input="name = $event.replace(/[^a-zA-Z0-9_-]/g, '')"
@keyup.enter="handleSave"
/>
</NFormItem>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, useMessage } from 'naive-ui'
import { NModal, NUpload, NButton, useMessage } from 'naive-ui'
import type { UploadFileInfo } from 'naive-ui'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
@@ -15,21 +16,39 @@ const message = useMessage()
const showModal = ref(true)
const loading = ref(false)
const archive = ref('')
const name = ref('')
const fileList = ref<UploadFileInfo[]>([])
const ACCEPT_TYPES = [
'.tar.gz',
'.tgz',
'.gz',
'.zip',
]
function beforeUpload({ file }: { file: UploadFileInfo }) {
const name = file.name?.toLowerCase() || ''
const valid = ACCEPT_TYPES.some(ext => name.endsWith(ext))
if (!valid) {
message.warning(t('profiles.importInvalidFile'))
return false
}
return true
}
async function handleSave() {
if (!archive.value.trim()) {
message.warning(t('profiles.archivePathPlaceholder'))
if (!fileList.value.length) {
message.warning(t('profiles.importSelectFile'))
return
}
loading.value = true
try {
const ok = await profilesStore.importProfile(
archive.value.trim(),
name.value.trim() || undefined,
)
const file = fileList.value[0].file
if (!file) {
message.error(t('profiles.importFailed'))
return
}
const ok = await profilesStore.importProfile(file)
if (ok) {
message.success(t('profiles.importSuccess'))
emit('saved')
@@ -56,26 +75,20 @@ function handleClose() {
:mask-closable="!loading"
@after-leave="emit('close')"
>
<NForm label-placement="top">
<NFormItem :label="t('profiles.archivePath')" required>
<NInput
v-model:value="archive"
:placeholder="t('profiles.archivePathPlaceholder')"
/>
</NFormItem>
<NFormItem :label="t('profiles.importName')">
<NInput
v-model:value="name"
:placeholder="t('profiles.importNamePlaceholder')"
/>
</NFormItem>
</NForm>
<NUpload
v-model:file-list="fileList"
:max="1"
:accept="ACCEPT_TYPES.join(',')"
:disabled="loading"
@before-upload="beforeUpload"
>
<NButton>{{ t('profiles.importSelectFile') }}</NButton>
</NUpload>
<template #footer>
<div class="modal-footer">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="loading" @click="handleSave">
<NButton type="primary" :loading="loading" :disabled="!fileList.length" @click="handleSave">
{{ t('common.confirm') }}
</NButton>
</div>
@@ -428,6 +428,13 @@ async function handleUpdate() {
padding-top: 12px;
flex-direction: column;
gap: 4px;
overflow-y: auto;
min-height: 0;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.nav-item {
@@ -2,12 +2,10 @@
import { computed, onMounted } from 'vue'
import { NSelect } from 'naive-ui'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useAppStore } from '@/stores/hermes/app'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const profilesStore = useProfilesStore()
const appStore = useAppStore()
const options = computed(() =>
profilesStore.profiles.map(p => ({
+3 -1
View File
@@ -232,8 +232,10 @@ export default {
exportFailed: 'Failed to export profile',
importSuccess: 'Profile imported',
importFailed: 'Failed to import profile',
importSelectFile: 'Select archive file',
importInvalidFile: 'Please select a valid archive (.tar.gz, .tgz, .gz, .zip)',
name: 'Profile Name',
namePlaceholder: 'Enter profile name',
namePlaceholder: 'English letters, numbers, hyphens only',
newName: 'New Name',
newNamePlaceholder: 'Enter new name',
cloneFromCurrent: 'Clone from current profile',
+3 -1
View File
@@ -232,8 +232,10 @@ export default {
exportFailed: '导出配置失败',
importSuccess: '配置已导入',
importFailed: '导入配置失败',
importSelectFile: '选择归档文件',
importInvalidFile: '请选择有效的归档文件 (.tar.gz, .tgz, .gz, .zip)',
name: '配置名称',
namePlaceholder: '输入配置名称',
namePlaceholder: '仅限英文、数字、连字符',
newName: '新名称',
newNamePlaceholder: '输入新名称',
cloneFromCurrent: '从当前配置克隆',
@@ -72,8 +72,8 @@ export const useProfilesStore = defineStore('profiles', () => {
return profilesApi.exportProfile(name)
}
async function importProfile(archive: string, name?: string) {
const ok = await profilesApi.importProfile(archive, name)
async function importProfile(file: File) {
const ok = await profilesApi.importProfile(file)
if (ok) await fetchProfiles()
return ok
}