feat: fix CSV upload state and UI indicators to prevent duplicate processing

This commit is contained in:
ws50529
2026-05-20 17:35:10 +08:00
parent 345b4a789a
commit 2f392b13c6
6 changed files with 124 additions and 39 deletions
+29 -11
View File
@@ -29,16 +29,12 @@ bash certs/generate_cert.sh
開啟:
```text
https://localhost:8000/ui/
https://192.168.2.58:8000/ui/
```
若從其他主機連線,改用:
本專案固定從 FastAPI 提供的 HTTPS UI 使用。不要用 Vite dev server URL 測試 CSV 或 MCU 功能,否則 `/api/...` 可能會回 `Not Found`
```text
https://<server-ip>:8000/ui/
```
自簽憑證會讓瀏覽器顯示安全性警告,需手動信任或選擇繼續前往。
自簽憑證會讓瀏覽器顯示安全性警告,第一次開啟時需手動信任或選擇繼續前往。
## Project Layout
@@ -73,8 +69,10 @@ chirp_signal.csv CSV 上傳測試範例
CSV workflow
1. 前端選擇 CSV。
2. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`
3. 後續 `POST /api/filter``POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔
2. 前端只讀取檔案前段內容建立欄位與 preview,避免為了 preview 把大型 CSV 全部載入瀏覽器記憶體
3. 前端呼叫 `POST /api/csv/upload`,後端將檔案暫存並回傳 `file_id`
4. 上傳完成前,filter 按鈕會保持 disabled,避免尚未取得 `file_id` 就送出處理。
5. 後續 `POST /api/filter``POST /api/filter/download` 使用 `file_id`,避免反覆傳大檔。
CSV 預設暫存在:
@@ -88,6 +86,12 @@ CSV 預設暫存在:
DEA_TEMP_CSV_DIR=/path/to/csv-cache
```
暫存 CSV 預設保留 24 小時。可用環境變數調整,設為 `0` 可停用清理:
```bash
DEA_TEMP_CSV_TTL_SECONDS=86400
```
## Usage
### Filter Design
@@ -210,7 +214,11 @@ npm run build
npm run dev
```
目前 `vite.config.js` 保留 `main` 設定,不在 Vite 內設定 `/api` proxy。
CSV 與 API 流程保留 `dev-v2` 的兩段式設計:先 upload 取得 `file_id`,再用 `file_id` filter 或 download。`vite.config.js` 保留 `main` 的 HTTPS deployment 設定,不在 Vite 內設定 `/api` proxy。因此 `npm run dev` 只適合檢查前端畫面;CSV upload、filter API、MCU API 請固定使用:
```text
https://192.168.2.58:8000/ui/
```
### Backend
@@ -309,9 +317,19 @@ HTTP responses 會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosn
## Troubleshooting
### CSV 上傳顯示 `Not Found`
請確認目前網址是:
```text
https://192.168.2.58:8000/ui/
```
如果從 `http://localhost:5173` 或其他 Vite dev server URL 開啟,`/api/csv/upload` 會送到前端 dev server,而不是 FastAPI,因此會回 `Not Found`
### 無法連線 MCU
1. 確認使用 HTTPS 或 localhost
1. 確認使用 `https://192.168.2.58:8000/ui/`
2. 確認瀏覽器支援 Web Serial API。
3. 使用 Chrome 或 Edge。
4. 檢查 USB 線與 MCU 裝置狀態。
+20
View File
@@ -3,6 +3,7 @@ import os
import uuid
import re
import tempfile
import time
import numpy as np
import pandas as pd
@@ -16,8 +17,26 @@ TEMP_CSV_DIR = os.environ.get(
"DEA_TEMP_CSV_DIR",
os.path.join(tempfile.gettempdir(), "diff-eq-analyzer-csv"),
)
TEMP_CSV_TTL_SECONDS = int(os.environ.get("DEA_TEMP_CSV_TTL_SECONDS", str(24 * 60 * 60)))
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
def prune_expired_csv_uploads(now=None):
if TEMP_CSV_TTL_SECONDS <= 0:
return
os.makedirs(TEMP_CSV_DIR, exist_ok=True)
cutoff = (now or time.time()) - TEMP_CSV_TTL_SECONDS
for filename in os.listdir(TEMP_CSV_DIR):
if not filename.endswith(".csv"):
continue
path = os.path.join(TEMP_CSV_DIR, filename)
try:
if os.path.isfile(path) and os.path.getmtime(path) < cutoff:
os.remove(path)
except OSError:
pass
def get_cached_file_path(file_id):
if not re.match(r'^[0-9a-f\-]{36}$', file_id):
raise HTTPException(status_code=400, detail="無效的檔案ID")
@@ -36,6 +55,7 @@ def downsample_for_plot(index, original, filtered_float, filtered_fixed):
async def save_csv_upload(file):
prune_expired_csv_uploads()
filename = (file.filename or "").lower()
if filename and not filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="請上傳 CSV 檔案")
+7 -3
View File
@@ -736,7 +736,11 @@
csvFile.name }}</span>
<span v-if="csvInfo"
class="text-sm text-slate-600 dark:text-gray-400 font-mono">
{{ csvInfo.rows }} / {{ csvInfo.columns }}
{{ csvInfo.rowsLabel || csvInfo.rows }} / {{ csvInfo.columns }}
</span>
<span v-if="csvUploading"
class="text-sm role-text-primary font-semibold">
上傳中...
</span>
<span v-if="csvParseError"
class="text-sm role-text-error font-semibold">
@@ -756,8 +760,8 @@
<button v-if="csvFile" @click="processFilter"
class="role-bg-primary role-hover-primary role-text-on-fill px-6 py-3 rounded-lg text-base font-semibold transition-all shadow-md role-shadow-primary disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loadingFilter || csvColumns.length === 0">
{{ loadingFilter ? '處理中...' : '執行當前濾波器' }}
:disabled="csvUploading || loadingFilter || csvColumns.length === 0">
{{ csvUploading ? '上傳中...' : (loadingFilter ? '處理中...' : '執行當前濾波器') }}
</button>
<button v-if="filterDone" @click="downloadCsv"
+21 -2
View File
@@ -44,6 +44,7 @@ export default {
loadingBode: false, bodeTimeout: null, globalError: null,
csvFile: null, csvFileId: null, csvColumns: [], csvPreview: [], csvInfo: null, csvParseError: null,
csvUploading: false,
isCsvPreviewExpanded: true,
selectedColumn: 0, loadingFilter: false, filterDone: false, timePlotData: null,
mobileTab: 'settings', // 手機版頁籤:'settings' | 'chart'
@@ -1242,6 +1243,7 @@ export default {
this.csvPreview = [];
this.csvInfo = null;
this.csvParseError = null;
this.csvUploading = false;
this.isCsvPreviewExpanded = true;
this.selectedColumn = 0;
this.filterDone = false;
@@ -1266,6 +1268,7 @@ export default {
return;
}
const previewBytes = Math.min(file.size, 1024 * 1024);
const reader = new FileReader();
reader.onload = async (e) => {
try {
@@ -1288,6 +1291,7 @@ export default {
});
this.csvInfo = {
rows: Math.max(totalRows - 1, 0),
rowsLabel: file.size > previewBytes ? `預覽 ${Math.max(rows.length - 1, 0)}+` : `${Math.max(totalRows - 1, 0)}`,
columns: columns.length,
size: file.size,
};
@@ -1300,10 +1304,18 @@ export default {
}
// 背景上傳至暫存區
this.csvUploading = true;
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/csv/upload', { method: 'POST', body: formData });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || '背景上傳失敗'); }
if (!res.ok) {
const errText = await res.text();
let detail = errText || '背景上傳失敗';
try {
detail = JSON.parse(errText).detail || detail;
} catch (_) {}
throw new Error(detail);
}
const data = await res.json();
this.csvFileId = data.file_id;
@@ -1314,13 +1326,16 @@ export default {
this.csvInfo = null;
this.csvFileId = null;
event.target.value = '';
} finally {
this.csvUploading = false;
}
};
reader.onerror = () => {
this.csvParseError = 'CSV 讀取失敗';
this.csvUploading = false;
event.target.value = '';
};
reader.readAsText(file);
reader.readAsText(file.slice(0, previewBytes));
},
debouncedProcessFilter() {
@@ -1332,6 +1347,10 @@ export default {
},
async processFilter() {
if (this.csvUploading) {
this.globalError = "CSV 還在上傳中,請稍候...";
return;
}
if (!this.csvFileId) {
if (this.csvFile) this.globalError = "檔案還在上傳中,請稍候...";
return;
+3 -3
View File
File diff suppressed because one or more lines are too long
+44 -20
View File
@@ -1,8 +1,7 @@
import asyncio
import io
import unittest
from fastapi import HTTPException, UploadFile
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from dea_api import (
@@ -15,11 +14,42 @@ from dea_api import (
calculate_bode_compare,
design_filter,
filter_csv,
upload_csv,
write_mcu_command,
)
class InMemoryUpload:
def __init__(self, content, filename):
self.content = content
self.filename = filename
async def read(self, _size=None):
return self.content
class DeaApiTest(unittest.TestCase):
async def upload_and_filter_csv(self, upload, b="1", a="1", col_idx=0, **kwargs):
upload_body = await upload_csv(upload)
self.assertIn("file_id", upload_body)
params = {
"b_int": None,
"a_int": None,
"shift_in": 14,
"shift_out": 14,
"shift_b": 14,
"shift_a": 14,
"use_round": False,
}
params.update(kwargs)
return await filter_csv(
file_id=upload_body["file_id"],
b=b,
a=a,
col_idx=col_idx,
**params,
)
def test_design_returns_normalized_coefficients_for_default_lowpass(self):
body = design_filter(
DesignParams(
@@ -100,12 +130,9 @@ class DeaApiTest(unittest.TestCase):
def test_filter_downsamples_plot_response_for_large_csv(self):
rows = ["value"] + [str(i) for i in range(6001)]
upload = UploadFile(
io.BytesIO(("\n".join(rows) + "\n").encode("utf-8")),
filename="input.csv",
)
upload = InMemoryUpload(("\n".join(rows) + "\n").encode("utf-8"), "input.csv")
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
body = asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
self.assertEqual(body["total_points"], 6001)
self.assertLessEqual(body["plot_points"], 5000)
@@ -113,10 +140,10 @@ class DeaApiTest(unittest.TestCase):
def test_filter_rejects_non_numeric_signal_column(self):
upload = UploadFile(io.BytesIO(b"value\n1\nbad\n3\n"), filename="input.csv")
upload = InMemoryUpload(b"value\n1\nbad\n3\n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("非數值", exc.detail)
@@ -124,22 +151,19 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected non-numeric column validation to fail")
def test_filter_accepts_quoted_csv_fields_and_filters_selected_column(self):
upload = UploadFile(
io.BytesIO('label,value\n"a,1",1\n"a,2",2\n'.encode("utf-8")),
filename="input.csv",
)
upload = InMemoryUpload('label,value\n"a,1",1\n"a,2",2\n'.encode("utf-8"), "input.csv")
body = asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=1))
body = asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=1))
self.assertEqual(body["col_name"], "value")
self.assertEqual(body["original"], [1.0, 2.0])
self.assertEqual(body["filtered"], [1.0, 2.0])
def test_filter_rejects_non_csv_filename(self):
upload = UploadFile(io.BytesIO(b"value\n1\n"), filename="input.txt")
upload = InMemoryUpload(b"value\n1\n", "input.txt")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(upload_csv(upload))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("CSV", exc.detail)
@@ -147,10 +171,10 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected non-CSV upload validation to fail")
def test_filter_rejects_empty_csv_upload(self):
upload = UploadFile(io.BytesIO(b" \n"), filename="input.csv")
upload = InMemoryUpload(b" \n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(upload_csv(upload))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("CSV", exc.detail)
@@ -158,10 +182,10 @@ class DeaApiTest(unittest.TestCase):
raise AssertionError("Expected empty CSV validation to fail")
def test_filter_rejects_infinite_signal_values(self):
upload = UploadFile(io.BytesIO(b"value\n1\ninf\n3\n"), filename="input.csv")
upload = InMemoryUpload(b"value\n1\ninf\n3\n", "input.csv")
try:
asyncio.run(filter_csv(file=upload, b="1", a="1", col_idx=0))
asyncio.run(self.upload_and_filter_csv(upload, b="1", a="1", col_idx=0))
except HTTPException as exc:
self.assertEqual(exc.status_code, 400)
self.assertIn("有限", exc.detail)