feat: 支持 CellData 文件夹上传处理

This commit is contained in:
2026-06-25 04:43:04 +08:00
parent c36dd21838
commit 9349c549e9
5 changed files with 470 additions and 30 deletions
+221 -11
View File
@@ -15,6 +15,20 @@
@dragleave="onDragLeave"
@drop.prevent.stop="onDrop"
>
<span class="upload-card-label">容量数据</span>
<n-button
quaternary
circle
size="small"
class="upload-help-button"
aria-label="查看容量数据说明"
title="查看说明"
@click.stop="capacityHelpVisible = true"
>
<template #icon>
<n-icon><InformationCircleOutline /></n-icon>
</template>
</n-button>
<div class="upload-icon">
<n-icon size="24"><FolderOpenOutline /></n-icon>
</div>
@@ -59,20 +73,66 @@
/>
</section>
<section v-if="!taskInProgress" class="cell-data-card">
<div>
<h3>CellData</h3>
<p>刷新 CellData 数据。</p>
</div>
<section
v-if="!taskInProgress"
class="cell-data-upload-zone"
:class="{ dragover: isCellDataDragging, disabled: cellDataStarting }"
role="button"
tabindex="0"
:aria-disabled="cellDataStarting"
@click="cellDataFolderInput?.click()"
@keydown.enter.prevent="cellDataFolderInput?.click()"
@keydown.space.prevent="cellDataFolderInput?.click()"
@dragenter.prevent.stop="onCellDataDragEnter"
@dragover.prevent.stop="onCellDataDragOver"
@dragleave="onCellDataDragLeave"
@drop.prevent.stop="onCellDataDrop"
>
<span class="upload-card-label">CellData</span>
<n-button
type="primary"
secondary
:loading="cellDataStarting"
:disabled="working || remoteStarting || cellDataStarting"
@click="startCellDataProcessing"
quaternary
circle
size="small"
class="upload-help-button"
aria-label="查看 CellData 文件说明"
title="查看说明"
@click.stop="cellDataHelpVisible = true"
>
刷新 CellData
<template #icon>
<n-icon><InformationCircleOutline /></n-icon>
</template>
</n-button>
<div class="upload-icon">
<n-icon size="24"><FolderOpenOutline /></n-icon>
</div>
<div class="cell-data-upload-text">
<h3>拖拽文件夹到这里</h3>
<p>包含 700M、2.6G 等目录</p>
</div>
<div class="upload-zone-actions">
<span class="upload-hint">或者点击选择文件夹</span>
<n-space size="small" @click.stop @keydown.stop>
<n-button
type="primary"
secondary
:loading="cellDataStarting"
:disabled="working || remoteStarting || cellDataStarting"
@click="startCellDataProcessing"
>
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
远程刷新
</n-button>
</n-space>
</div>
<input
ref="cellDataFolderInput"
class="hidden-input"
type="file"
multiple
webkitdirectory
directory
@change="pickCellDataFiles"
/>
</section>
<div v-if="!taskInProgress && files.length > 0" class="file-list">
@@ -193,6 +253,58 @@
</div>
</template>
</n-modal>
<n-modal
v-model:show="capacityHelpVisible"
preset="card"
class="cell-data-help-modal"
title="容量数据说明"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
>
<n-scrollbar class="cell-data-help-scroll">
<section class="cell-data-help-section">
<h4>支持格式</h4>
<p>支持 ZIP、Excel 和 CSV 文件。</p>
</section>
<section class="cell-data-help-section">
<h4>上传方式</h4>
<p>可拖拽文件或文件夹,也可点击卡片选择文件。</p>
</section>
<section class="cell-data-help-section">
<h4>远程处理</h4>
<p>使用系统设置中的数据源配置下载并处理。</p>
</section>
</n-scrollbar>
</n-modal>
<n-modal
v-model:show="cellDataHelpVisible"
preset="card"
class="cell-data-help-modal"
title="CellData 文件说明"
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
>
<n-scrollbar class="cell-data-help-scroll">
<section class="cell-data-help-section">
<h4>目录结构</h4>
<p>请选择包含频段目录的文件夹,例如:</p>
<code>300表/700M/Result_300_*.zip</code>
<code>300表/2.6G/Result_300_*.zip</code>
</section>
<section class="cell-data-help-section">
<h4>文件要求</h4>
<p>只处理文件名以 <strong>Result_300_</strong> 开头的 ZIP。</p>
<p>每个频段目录只取文件名末尾时间戳最新的 ZIP。</p>
</section>
<section class="cell-data-help-section">
<h4>ZIP 内容</h4>
<p>识别以下 CSV 文件名前缀:</p>
<code>LTE_ITBBU_CellInfo</code>
<code>LTE_SDR_CellInfo</code>
<code>NR_CellInfo</code>
</section>
</n-scrollbar>
</n-modal>
</div>
</template>
@@ -204,6 +316,7 @@ import {
CloudUploadOutline,
DocumentTextOutline,
FolderOpenOutline,
InformationCircleOutline,
RefreshOutline,
TrashOutline
} from '@vicons/ionicons5';
@@ -238,12 +351,16 @@ const validExtensions = new Set(['.zip', '.xlsx', '.xls', '.csv']);
const message = useMessage();
const fileInput = ref<HTMLInputElement | null>(null);
const folderInput = ref<HTMLInputElement | null>(null);
const cellDataFolderInput = ref<HTMLInputElement | null>(null);
const files = ref<PickedFile[]>([]);
const uploadProgress = ref(0);
const working = ref(false);
const remoteStarting = ref(false);
const cellDataStarting = ref(false);
const isDragging = ref(false);
const isCellDataDragging = ref(false);
const capacityHelpVisible = ref(false);
const cellDataHelpVisible = ref(false);
const taskStatus = ref<TaskStatus | null>(null);
const activeTask = ref<ActiveTask | null>(null);
const keepLatestLog = ref(false);
@@ -320,6 +437,10 @@ onMounted(() => {
if (folder) {
folder.webkitdirectory = true;
}
const cellDataFolder = cellDataFolderInput.value as (HTMLInputElement & { webkitdirectory?: boolean }) | null;
if (cellDataFolder) {
cellDataFolder.webkitdirectory = true;
}
void checkActiveTask();
});
@@ -367,6 +488,43 @@ async function onDrop(event: DragEvent) {
addFiles(dropped);
}
function onCellDataDragEnter() {
if (!cellDataStarting.value && !taskInProgress.value) {
isCellDataDragging.value = true;
}
}
function onCellDataDragOver() {
if (!cellDataStarting.value && !taskInProgress.value) {
isCellDataDragging.value = true;
}
}
function onCellDataDragLeave(event: DragEvent) {
const target = event.currentTarget as HTMLElement;
const related = event.relatedTarget as Node | null;
if (!related || !target.contains(related)) {
isCellDataDragging.value = false;
}
}
async function onCellDataDrop(event: DragEvent) {
isCellDataDragging.value = false;
if (cellDataStarting.value || taskInProgress.value || !event.dataTransfer) return;
if (!hasDroppedDirectory(event.dataTransfer)) {
message.warning('请拖入包含 Result_300 ZIP 的文件夹');
return;
}
await startCellDataUpload(await readDroppedFiles(event.dataTransfer));
}
async function pickCellDataFiles(event: Event) {
const input = event.target as HTMLInputElement;
const selected = Array.from(input.files || []).map(toDroppedFile);
input.value = '';
await startCellDataUpload(selected);
}
async function readDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile[]> {
const result: DroppedFile[] = [];
const items = Array.from(dataTransfer.items || []);
@@ -386,6 +544,13 @@ async function readDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile
return Array.from(dataTransfer.files || []).map(toDroppedFile);
}
function hasDroppedDirectory(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.items || []).some(item => {
if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return false;
return Boolean(item.webkitGetAsEntry()?.isDirectory);
});
}
async function traverseEntry(entry: FileSystemEntry, parentPath: string): Promise<DroppedFile[]> {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
@@ -481,6 +646,11 @@ function isSupportedFile(path: string): boolean {
return validExtensions.has(path.slice(dotIndex).toLowerCase());
}
function isCellDataZip(path: string): boolean {
const name = normalizePath(path).split('/').pop() || '';
return /^Result_300_.*\.zip$/i.test(name);
}
function normalizePath(path: string): string {
return path.replace(/\\/g, '/');
}
@@ -589,6 +759,46 @@ async function startCellDataProcessing() {
}
}
async function startCellDataUpload(items: DroppedFile[]) {
if (cellDataStarting.value || taskInProgress.value) return;
const accepted = items
.map(item => ({ ...item, path: normalizePath(item.path) }))
.filter(item => isCellDataZip(item.path));
if (accepted.length === 0) {
message.warning('请选择 Result_300 ZIP 文件');
return;
}
const formData = new FormData();
for (const item of accepted) {
formData.append('files', item.file, item.path);
}
cellDataStarting.value = true;
try {
const result = await upload<UploadResponse>('/api/cell-data/process/upload', formData);
message.success(result.message || `已上传 ${accepted.length} 个文件`);
activeTask.value = {
has_active: true,
task_id: result.task_id,
stage: result.stage || 'parsing',
started_at: new Date().toISOString()
};
taskStatus.value = {
task_id: result.task_id,
status: 'processing',
stage: result.stage || 'parsing',
logs: ['CellData 文件已上传,等待处理日志...']
};
taskMode.value = 'cell_data';
startPolling(result.task_id);
} catch (error) {
message.error(error instanceof Error ? error.message : 'CellData 上传处理失败');
} finally {
cellDataStarting.value = false;
}
}
async function startUploadedTask(taskId: string) {
await apiPost('/api/process/start', { task_id: taskId });
taskMode.value = 'local';
+69 -9
View File
@@ -497,6 +497,21 @@ select {
transition: all var(--td-transition);
}
.upload-card-label {
position: absolute;
top: 12px;
left: 16px;
color: var(--td-text-color-primary);
font-size: 13px;
font-weight: 600;
}
.upload-help-button {
position: absolute;
top: 10px;
right: 10px;
}
.upload-zone:hover,
.upload-zone.dragover {
background: var(--td-brand-color-light);
@@ -559,35 +574,80 @@ select {
display: inline-flex;
}
.cell-data-card {
.cell-data-upload-zone {
display: flex;
position: relative;
align-items: center;
justify-content: space-between;
gap: 16px;
justify-content: center;
flex-direction: column;
gap: 10px;
margin-top: 16px;
padding: 16px 18px;
padding: 24px 18px;
text-align: center;
background: var(--td-bg-color-container);
border: 1px solid var(--td-border-color-light);
border: 1px dashed var(--td-border-color);
border-radius: var(--td-radius-large);
cursor: pointer;
transition: all var(--td-transition);
}
.cell-data-card h3,
.cell-data-card p {
.cell-data-upload-zone:hover,
.cell-data-upload-zone.dragover {
background: var(--td-brand-color-light);
border-color: var(--td-brand-color);
}
.cell-data-upload-zone.disabled {
cursor: not-allowed;
opacity: 0.72;
}
.cell-data-upload-text h3,
.cell-data-upload-text p {
margin: 0;
}
.cell-data-card h3 {
.cell-data-upload-text h3 {
color: var(--td-text-color-primary);
font-size: 15px;
font-weight: 600;
}
.cell-data-card p {
.cell-data-upload-text p {
margin-top: 4px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.cell-data-help-scroll {
max-height: 360px;
}
.cell-data-help-section {
display: grid;
gap: 6px;
padding-bottom: 14px;
}
.cell-data-help-section + .cell-data-help-section {
padding-top: 12px;
border-top: 1px solid var(--td-border-color-light);
}
.cell-data-help-section h4,
.cell-data-help-section p {
margin: 0;
}
.cell-data-help-section code {
display: block;
overflow-x: auto;
padding: 6px 8px;
color: var(--td-text-color-primary);
background: var(--td-bg-color-secondarycontainer);
border-radius: var(--td-radius-small);
}
.license-dialog-body {
display: flex;
flex-direction: column;