Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="upload-page"> | |
| <div class="upload-shell"> | |
| <nav class="crumb" aria-label="面包屑"> | |
| <router-link class="crumb-link" to="/projects">{{ t('upload.breadcrumbProjects') }}</router-link> | |
| <span class="crumb-sep">/</span> | |
| <router-link | |
| v-if="project" | |
| class="crumb-link" | |
| :to="{ name: 'project-detail', params: { id: projectId } }" | |
| > | |
| {{ project.name }} | |
| </router-link> | |
| <span v-else class="crumb-muted">{{ projectCrumbFallback }}</span> | |
| <span class="crumb-sep">/</span> | |
| <span class="crumb-current">{{ t('upload.breadcrumbCurrent') }}</span> | |
| </nav> | |
| <header class="upload-header"> | |
| <h1 class="upload-title">{{ t('upload.title') }}</h1> | |
| <p class="upload-lead"> | |
| {{ t('upload.subtitle') }} | |
| </p> | |
| </header> | |
| <form class="upload-grid" @submit.prevent="onSubmit"> | |
| <section class="panel"> | |
| <div class="panel-head"> | |
| <h2 class="panel-title">{{ step === 'confirm' ? t('upload.panels.groupInfo') : t('upload.panels.dataFile') }}</h2> | |
| </div> | |
| <div class="panel-body"> | |
| <template v-if="step === 'upload'"> | |
| <div class="field"> | |
| <label class="field-label" for="archive-file">{{ t('upload.fields.uploadFile') }}</label> | |
| <div class="file-row"> | |
| <button type="button" class="btn-file" @click="triggerFilePick"> | |
| {{ t('upload.fields.selectFile') }} | |
| </button> | |
| <span class="file-label">{{ fileDisplayName }}</span> | |
| </div> | |
| <input | |
| id="archive-file" | |
| ref="fileInputRef" | |
| type="file" | |
| accept=".zip,.tar,.tar.gz,.tgz,application/zip,application/x-tar,application/gzip" | |
| class="sr-only" | |
| @change="onFileChange" | |
| > | |
| <p class="field-hint"> | |
| {{ t('upload.fields.zipHint') }} | |
| </p> | |
| </div> | |
| <div class="field"> | |
| <label class="field-label" for="group-index">{{ t('upload.fields.groupIndex') }}</label> | |
| <input | |
| id="group-index" | |
| v-model="groupIndex" | |
| type="text" | |
| class="field-input" | |
| inputmode="numeric" | |
| autocomplete="off" | |
| :placeholder="t('upload.fields.groupIndexPlaceholder')" | |
| > | |
| <p class="field-hint field-hint--block"> | |
| {{ t('upload.fields.groupIndexHint') }} | |
| </p> | |
| </div> | |
| <div class="field"> | |
| <label class="field-label" for="wave-range">{{ t('upload.fields.wavenumberRange') }}</label> | |
| <input | |
| id="wave-range" | |
| v-model="wavenumberRange" | |
| type="text" | |
| class="field-input" | |
| autocomplete="off" | |
| placeholder="500,3150" | |
| > | |
| <p class="field-hint field-hint--block"> | |
| {{ t('upload.fields.wavenumberHint') }} | |
| </p> | |
| </div> | |
| <p v-if="submitError" class="form-error">{{ submitError }}</p> | |
| <div class="form-actions"> | |
| <button type="submit" class="btn-primary" :disabled="submitting"> | |
| {{ submitting ? t('upload.actions.uploading') : t('upload.actions.upload') }} | |
| </button> | |
| <button type="button" class="btn-secondary" :disabled="submitting" @click="goBack"> | |
| {{ t('upload.actions.back') }} | |
| </button> | |
| </div> | |
| </template> | |
| <template v-else> | |
| <div class="group-card"> | |
| <div class="field field--tight"> | |
| <label class="field-label" for="group-names">{{ t('upload.fields.groupNames') }}</label> | |
| <textarea | |
| id="group-names" | |
| v-model="groupNamesDraft" | |
| class="field-input group-textarea" | |
| rows="6" | |
| spellcheck="false" | |
| autocomplete="off" | |
| ></textarea> | |
| <p class="field-hint field-hint--block"> | |
| {{ t('upload.fields.groupNamesHint') }} | |
| </p> | |
| </div> | |
| </div> | |
| <p v-if="confirmError" class="form-error">{{ confirmError }}</p> | |
| <div class="form-actions"> | |
| <button type="button" class="btn-secondary" :disabled="submitting" @click="backToUploadStep"> | |
| {{ t('upload.actions.editGroupIndex') }} | |
| </button> | |
| <button type="button" class="btn-primary" :disabled="submitting" @click="confirmGroups"> | |
| {{ submitting ? t('upload.actions.confirming') : t('upload.actions.confirmGroups') }} | |
| </button> | |
| <button type="button" class="btn-secondary" :disabled="submitting" @click="goBack"> | |
| {{ t('upload.actions.backToProject') }} | |
| </button> | |
| </div> | |
| </template> | |
| </div> | |
| </section> | |
| <aside class="panel panel--side"> | |
| <div class="panel-head"> | |
| <h2 class="panel-title">{{ t('upload.panels.requirements') }}</h2> | |
| </div> | |
| <div class="panel-body panel-body--help"> | |
| <div class="help-block"> | |
| <h3 class="help-title">{{ t('upload.help.formatTitle') }}</h3> | |
| <p class="help-text" v-html="t('upload.help.formatBody')"></p> | |
| </div> | |
| <div class="help-block"> | |
| <h3 class="help-title">{{ t('upload.help.structureTitle') }}</h3> | |
| <p class="help-text">{{ t('upload.help.structureBody') }}</p> | |
| </div> | |
| <div class="help-block"> | |
| <h3 class="help-title">{{ t('upload.help.rangeTitle') }}</h3> | |
| <p class="help-text" v-html="t('upload.help.rangeBody')"></p> | |
| </div> | |
| <div class="help-block"> | |
| <h3 class="help-title">{{ t('upload.help.groupTitle') }}</h3> | |
| <p class="help-text">{{ t('upload.help.groupBody') }}</p> | |
| </div> | |
| <div class="help-callout"> | |
| {{ t('upload.help.support') }} | |
| </div> | |
| </div> | |
| </aside> | |
| </form> | |
| </div> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { ref, computed } from 'vue' | |
| import { useI18n } from 'vue-i18n' | |
| import { useRoute, useRouter } from 'vue-router' | |
| import { | |
| ApiError, | |
| type BackendProject, | |
| getProject, | |
| uploadProjectFile | |
| } from '../api/http' | |
| type UploadProjectFileResponse = { | |
| status?: string | |
| group_names?: unknown | |
| next_steps?: unknown | |
| message?: string | |
| } | |
| const { t } = useI18n() | |
| const route = useRoute() | |
| const router = useRouter() | |
| const projectId = computed(() => String(route.params.id ?? '')) | |
| const projectCrumbFallback = computed(() => { | |
| const id = projectId.value | |
| return id | |
| ? t('upload.projectIdPreview', { id: id.slice(0, 8) }) | |
| : t('upload.projectFallback') | |
| }) | |
| const fileInputRef = ref<HTMLInputElement | null>(null) | |
| const project = ref<BackendProject | null>(null) | |
| const selectedFile = ref<File | null>(null) | |
| const step = ref<'upload' | 'confirm'>('upload') | |
| const uploadResponse = ref<UploadProjectFileResponse | null>(null) | |
| const groupNamesDraft = ref<string>('[]') | |
| const confirmError = ref('') | |
| const groupIndex = ref('2') | |
| const wavenumberRange = ref('500,3150') | |
| const submitting = ref(false) | |
| const submitError = ref('') | |
| const fileDisplayName = computed(() => | |
| selectedFile.value ? selectedFile.value.name : t('upload.noFileSelected') | |
| ) | |
| const toPrettyJson = (value: unknown) => JSON.stringify(value, null, 2) | |
| const triggerFilePick = () => { | |
| fileInputRef.value?.click() | |
| } | |
| const onFileChange = (e: Event) => { | |
| const input = e.target as HTMLInputElement | |
| const f = input.files?.[0] | |
| selectedFile.value = f ?? null | |
| submitError.value = '' | |
| } | |
| const goBack = () => { | |
| void router.push({ name: 'project-detail', params: { id: projectId.value } }) | |
| } | |
| const normalizeGroupNames = (input: unknown): string[] | null => { | |
| if (!Array.isArray(input)) return null | |
| const normalized = input | |
| .map(v => (typeof v === 'string' ? v.trim() : '')) | |
| .filter(v => v.length > 0) | |
| if (normalized.length === 0) return null | |
| return normalized | |
| } | |
| const parseGroupNamesDraft = (): string[] | null => { | |
| try { | |
| const parsed = JSON.parse(groupNamesDraft.value) | |
| return normalizeGroupNames(parsed) | |
| } catch { | |
| return null | |
| } | |
| } | |
| const backToUploadStep = () => { | |
| step.value = 'upload' | |
| confirmError.value = '' | |
| } | |
| const confirmGroups = async () => { | |
| confirmError.value = '' | |
| const id = projectId.value | |
| if (!id) { | |
| confirmError.value = t('common.errors.invalidProject') | |
| return | |
| } | |
| const groupNames = parseGroupNamesDraft() | |
| if (!groupNames) { | |
| confirmError.value = t('common.errors.groupJsonInvalid') | |
| return | |
| } | |
| // 目前代码库中未发现“确认分组”的独立接口: | |
| // 按需求:确认后才触发 getProject,并直接跳转到 /editor。 | |
| submitting.value = true | |
| try { | |
| project.value = await getProject(id) | |
| await router.push({ name: 'editor', query: { projectId: id } }) | |
| } catch (e) { | |
| confirmError.value = e instanceof ApiError ? e.message : t('common.errors.confirm') | |
| } finally { | |
| submitting.value = false | |
| } | |
| } | |
| const onSubmit = async () => { | |
| submitError.value = '' | |
| const id = projectId.value | |
| if (!id) { | |
| submitError.value = t('common.errors.invalidProject') | |
| return | |
| } | |
| const file = selectedFile.value | |
| if (!file) { | |
| submitError.value = t('common.errors.selectArchive') | |
| return | |
| } | |
| if (!['.zip', '.tar', '.tar.gz', '.tgz'].some(ext => file.name.toLowerCase().endsWith(ext))) { | |
| submitError.value = t('common.errors.archiveOnly') | |
| return | |
| } | |
| submitting.value = true | |
| try { | |
| const res = await uploadProjectFile(id, file, { | |
| groupIndex: groupIndex.value, | |
| wavenumberRange: wavenumberRange.value | |
| }) | |
| const resp = res as UploadProjectFileResponse | |
| uploadResponse.value = resp | |
| const groupNames = normalizeGroupNames(resp?.group_names) | |
| if (!groupNames) { | |
| // 若后端未返回可识别的 group_names,则仍回到项目详情 | |
| await router.push({ name: 'project-detail', params: { id } }) | |
| return | |
| } | |
| groupNamesDraft.value = toPrettyJson(groupNames) | |
| confirmError.value = '' | |
| step.value = 'confirm' | |
| } catch (e) { | |
| submitError.value = e instanceof ApiError ? e.message : t('common.errors.upload') | |
| } finally { | |
| submitting.value = false | |
| } | |
| } | |
| </script> | |
| <style scoped> | |
| .upload-page { | |
| min-height: 100vh; | |
| background: transparent; | |
| padding: 2rem 1.5rem 3rem; | |
| } | |
| .upload-shell { | |
| max-width: 1120px; | |
| margin: 0 auto; | |
| } | |
| .crumb { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 0.35rem; | |
| font-size: 0.8125rem; | |
| color: #64748b; | |
| margin-bottom: 1.25rem; | |
| } | |
| .crumb-link { | |
| color: #64748b; | |
| text-decoration: none; | |
| } | |
| .crumb-link:hover { | |
| color: #0f172a; | |
| text-decoration: underline; | |
| } | |
| .crumb-sep { | |
| color: #cbd5e1; | |
| user-select: none; | |
| } | |
| .crumb-muted { | |
| color: #94a3b8; | |
| } | |
| .crumb-current { | |
| color: #0f172a; | |
| font-weight: 600; | |
| } | |
| .upload-header { | |
| margin-bottom: 1.75rem; | |
| } | |
| .upload-title { | |
| margin: 0 0 0.5rem; | |
| font-size: 1.5rem; | |
| font-weight: 650; | |
| letter-spacing: -0.02em; | |
| color: #0f172a; | |
| } | |
| .upload-lead { | |
| margin: 0; | |
| font-size: 0.9375rem; | |
| color: #64748b; | |
| line-height: 1.55; | |
| } | |
| .banner { | |
| padding: 0.875rem 1rem; | |
| border-radius: 2px; | |
| font-size: 0.9rem; | |
| margin-bottom: 1.25rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .banner--warn { | |
| background: #f8fafc; | |
| border: 1px solid #e2e8f0; | |
| color: #475569; | |
| } | |
| .banner--error { | |
| background: #fef2f2; | |
| border: 1px solid #fecaca; | |
| color: #991b1b; | |
| } | |
| .btn-inline { | |
| border: 1px solid #991b1b; | |
| background: #fff; | |
| color: #991b1b; | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| padding: 0.35rem 0.85rem; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| } | |
| .upload-grid { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) minmax(260px, 340px); | |
| gap: 1.25rem; | |
| align-items: start; | |
| } | |
| @media (max-width: 900px) { | |
| .upload-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .panel { | |
| background: #fff; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 2px; | |
| box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); | |
| } | |
| .panel--side { | |
| position: sticky; | |
| top: 1rem; | |
| } | |
| .panel-head { | |
| padding: 0.65rem 1.1rem; | |
| background: #f8fafc; | |
| border-bottom: 1px solid #e2e8f0; | |
| } | |
| .panel-title { | |
| margin: 0; | |
| font-size: 0.875rem; | |
| font-weight: 650; | |
| color: #0f172a; | |
| } | |
| .panel-body { | |
| padding: 1.25rem 1.35rem 1.5rem; | |
| } | |
| .panel-body--help { | |
| padding-bottom: 1.35rem; | |
| } | |
| .field { | |
| margin-bottom: 1.35rem; | |
| } | |
| .field--tight { | |
| margin-bottom: 0; | |
| } | |
| .field:last-of-type { | |
| margin-bottom: 1rem; | |
| } | |
| .field-label { | |
| display: block; | |
| font-size: 0.75rem; | |
| font-weight: 650; | |
| letter-spacing: 0.04em; | |
| text-transform: uppercase; | |
| color: #64748b; | |
| margin-bottom: 0.45rem; | |
| } | |
| .file-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| flex-wrap: wrap; | |
| } | |
| .btn-file { | |
| padding: 0.4rem 0.95rem; | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| color: #fff; | |
| background: var(--ramex-accent); | |
| border: none; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| transition: background 0.15s ease; | |
| } | |
| .btn-file:hover { | |
| background: #1d4ed8; | |
| } | |
| .file-label { | |
| font-size: 0.875rem; | |
| color: #64748b; | |
| } | |
| .field-input { | |
| width: 100%; | |
| box-sizing: border-box; | |
| padding: 0.55rem 0.75rem; | |
| font-size: 0.9rem; | |
| color: #0f172a; | |
| background: #fafbfc; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 2px; | |
| transition: border-color 0.15s ease, background 0.15s ease; | |
| } | |
| .field-input:focus { | |
| outline: none; | |
| border-color: #94a3b8; | |
| background: #fff; | |
| } | |
| .field-input::placeholder { | |
| color: #94a3b8; | |
| } | |
| .group-textarea { | |
| min-height: 160px; | |
| resize: vertical; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; | |
| font-size: 0.86rem; | |
| line-height: 1.55; | |
| white-space: pre; | |
| } | |
| .field-hint { | |
| margin: 0.4rem 0 0; | |
| font-size: 0.8rem; | |
| color: #94a3b8; | |
| line-height: 1.5; | |
| } | |
| .field-hint--block { | |
| margin-top: 0.5rem; | |
| } | |
| .field-hint code { | |
| font-size: 0.85em; | |
| padding: 0.05rem 0.25rem; | |
| background: #f1f5f9; | |
| border-radius: 2px; | |
| color: #475569; | |
| } | |
| .form-error { | |
| margin: 0 0 1rem; | |
| padding: 0.55rem 0.65rem; | |
| font-size: 0.85rem; | |
| color: #991b1b; | |
| background: #fef2f2; | |
| border: 1px solid #fecaca; | |
| border-radius: 2px; | |
| } | |
| .form-actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| gap: 0.65rem; | |
| margin-top: 1.5rem; | |
| padding-top: 1rem; | |
| border-top: 1px solid #f1f5f9; | |
| } | |
| .btn-primary { | |
| padding: 0.5rem 1.35rem; | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| color: #fff; | |
| background: #0f172a; | |
| border: none; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| transition: background 0.15s ease, opacity 0.15s ease; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| background: #1e293b; | |
| } | |
| .btn-primary:disabled { | |
| opacity: 0.55; | |
| cursor: not-allowed; | |
| } | |
| .btn-secondary { | |
| padding: 0.5rem 1.25rem; | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| color: #334155; | |
| background: #fff; | |
| border: 1px solid #cbd5e1; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| transition: background 0.15s ease, border-color 0.15s ease; | |
| } | |
| .btn-secondary:hover:not(:disabled) { | |
| background: #f8fafc; | |
| border-color: #94a3b8; | |
| } | |
| .help-block { | |
| margin-bottom: 1.1rem; | |
| } | |
| .help-block:last-of-type { | |
| margin-bottom: 1rem; | |
| } | |
| .help-title { | |
| margin: 0 0 0.35rem; | |
| font-size: 0.8125rem; | |
| font-weight: 650; | |
| color: #0f172a; | |
| } | |
| .help-text { | |
| margin: 0; | |
| font-size: 0.8125rem; | |
| line-height: 1.6; | |
| color: #64748b; | |
| } | |
| .help-callout { | |
| padding: 0.85rem 1rem; | |
| font-size: 0.8rem; | |
| line-height: 1.55; | |
| color: #1e40af; | |
| background: var(--ramex-bg-subtle); | |
| border: 1px solid var(--ramex-border); | |
| border-radius: 2px; | |
| } | |
| .sr-only { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| padding: 0; | |
| margin: -1px; | |
| overflow: hidden; | |
| clip: rect(0, 0, 0, 0); | |
| white-space: nowrap; | |
| border: 0; | |
| } | |
| </style> | |