feat: fix CSV upload state and UI indicators to prevent duplicate processing
This commit is contained in:
@@ -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 裝置狀態。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+44
-20
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user