refactor: update Tailwind CSS utility classes and project configuration files

This commit is contained in:
ws50529
2026-05-15 11:34:13 +08:00
parent 68b003fd80
commit 425f680614
7 changed files with 1764 additions and 1660 deletions
+80 -120
View File
@@ -6,10 +6,11 @@ Difference Equation Analyzer 是一個用來設計、檢視與驗證離散時間
- 設計 Lowpass、Highpass、Bandpass、Notch、1P1Z、2P1Z、2P2Z、PID、SOGI 濾波器。
- 比較浮點係數與 fixed-point 係數的頻率響應。
- 手動微調 b/a 係數、a1/a2 組合、Q 格式位元數與 fixed-point 整數係數。
- 手動微調 b/a 係數、a1/a2 組合(支援 δ/r 參數微調)、Q 格式位元數與 fixed-point 整數係數。
- 透過瀏覽器 Web Serial API 直接將 fixed-point 係數命令送到 MCU(需透過 HTTPS 連線)。
- 上傳 CSV 時域資料,套用目前差分方程並繪製輸入/輸出波形。
- 匯出包含濾波輸出欄位的 CSV。
- 透過 USB serial 將 fixed-point 係數命令送到 MCU
- 支援深色模式 (Dark Mode) 與響應式 UI
## 專案結構
@@ -17,14 +18,15 @@ Difference Equation Analyzer 是一個用來設計、檢視與驗證離散時間
dea_api.py FastAPI 入口與 HTTP route
dea/ 後端核心模組
bode.py 頻率響應計算
config.py 限制值與 MCU serial 設定
config.py 限制值與伺服器端 serial 設定
csv_processing.py CSV 讀取、驗證、濾波與匯出
filter_design.py 濾波器設計
mcu.py MCU serial port 列表與命令寫入
mcu.py MCU serial port 列表與命令寫入 (後端備用支援)
schemas.py Pydantic request schema
security.py LAN 存取限制與安全標頭
validation.py 係數、頻率與有限值驗證
src/ Vue 3 前端原始碼
certs/ HTTPS 自簽憑證與產生腳本
static/ FastAPI 對外提供的靜態 UI
static/build/ Vite build 輸出
tests/ unittest 測試
@@ -42,86 +44,66 @@ python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
安裝前端依賴:
安裝前端依賴並建置
```bash
npm install
```
建置前端:
```bash
npm run build
```
Vite 會輸出到:
## HTTPS 設定
```text
static/build/app.js
static/build/app.css
```
為了支援 Web Serial API(瀏覽器直接存取本機連接埠),後端必須透過 HTTPS 連線提供服務。
1. **產生自簽憑證**
```bash
bash certs/generate_cert.sh
```
這會在 `certs/` 目錄下產生 `key.pem` 與 `cert.pem`。
2. **信任憑證**
由於是自簽憑證,瀏覽器會顯示安全性警告。請在「進階」中選擇「繼續前往」或將其加入信任清單。
## 執行
本機開發可直接啟動 FastAPI
### 手動啟動 (HTTPS)
```bash
.venv/bin/uvicorn dea_api:app --host 127.0.0.1 --port 8000
.venv/bin/uvicorn dea_api:app --host 0.0.0.0 --port 8000 --ssl-keyfile certs/key.pem --ssl-certfile certs/cert.pem
```
瀏覽器開啟:
```text
http://localhost:8000/ui/
```
部署在區網主機時可用:
```text
http://<server-ip>:8000/ui/
```
瀏覽器開啟:`https://<server-ip>:8000/ui/` 或本機 `https://localhost:8000/ui/`
根路徑 `/` 會自動導向 `/ui/`。
## API
### 使用 systemd
目前主要 API
1. 檢查 `dea.service` 中的路徑是否正確(需確認 `ExecStart` 已加上 `--ssl-keyfile` 等參數)。
2. 啟用並啟動服務:
```bash
sudo cp dea.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable dea.service
sudo systemctl start dea.service
```
- `POST /api/design`:依濾波器參數產生 b/a 係數。
- `POST /api/bode`:計算單組 b/a 係數的頻率響應。
- `POST /api/bode/compare`:一次計算浮點與 fixed-point 兩組頻率響應。
- `POST /api/filter`:上傳 CSV 並回傳圖表用的濾波結果。
- `POST /api/filter/download`:上傳 CSV 並下載包含 `<欄位>_filtered` 的結果 CSV。
- `GET /api/mcu/ports`:列出可用 serial ports。
- `POST /api/mcu/write`:送出 MCU 命令。
MCU 寫入命令格式固定為:
```text
bodeplot=b0,b1,b2,a1,a2
查看狀態與 Log
```bash
sudo systemctl status dea.service
sudo journalctl -u dea.service -f
```
前端會使用目前 fixed-point 係數自動組出此命令。
## MCU 連接埠 (Web Serial)
## CSV 上傳
本工具優先使用瀏覽器的 Web Serial API 直接與 MCU 通訊,無需伺服器端具備 serial 權限。
限制與行為:
- **連線**:點擊 UI 上的「選擇連接埠並連線」,在瀏覽器彈出視窗中選擇正確的 MCU 裝置。
- **寫值**:點擊「寫值到 MCU」,系統會將目前定點數係數格式化為 `bodeplot=b0,b1,b2,a1,a2` 並送出。
- **限制**:需使用 Chrome 或 Edge 瀏覽器,且必須透過 HTTPS (或 localhost) 連線。
- CSV 檔案大小上限為 32 MB。
- CSV 必須有標題列與至少一筆資料。
- 可選擇要濾波的訊號欄位;若第一欄像 `time``t``sec``x``index`,前端會預設選第二欄。
- 圖表回應最多回傳 5000 點,避免大型 CSV 讓前端過重。
- 下載 CSV 會保留完整資料,不套用圖表降採樣。
## 後端 MCU Serial 設定 (備用)
專案根目錄的 `chirp_signal.csv` 是上傳測試範例,約 19 MB、500001 行,欄位為
```text
time,value
```
## MCU Serial 設定
MCU serial 預設值在 `dea/config.py`,也可用環境變數覆蓋:
若無法使用 Web Serial,後端仍保留透過伺服器端實體連線寫入的 API。預設值在 `dea/config.py`,也可用環境變數覆蓋
```bash
MCU_SERIAL_PORT=/dev/ttyUSB2
@@ -137,42 +119,47 @@ Environment="MCU_BAUD_RATE=115200"
Environment="MCU_WRITE_TIMEOUT=1.0"
```
更新 service 後重新載入:
## API
```bash
sudo systemctl daemon-reload
sudo systemctl restart dea.service
目前主要 API
- `POST /api/design`:依濾波器參數產生 b/a 係數。
- `POST /api/bode`:計算單組 b/a 係數的頻率響應。
- `POST /api/bode/compare`:一次計算浮點與 fixed-point 兩組頻率響應。
- `POST /api/filter`:上傳 CSV 並回傳圖表用的濾波結果。
- `POST /api/filter/download`:上傳 CSV 並下載包含 `<欄位>_filtered` 的結果 CSV。
- `GET /api/mcu/ports`:列出伺服器端可用 serial ports (後端模式)。
- `POST /api/mcu/write`:送出伺服器端 MCU 命令 (後端模式)。
MCU 寫入命令格式固定為:
```text
bodeplot=b0,b1,b2,a1,a2
```
注意:MCU 功能需要 `pyserial>=3.5`,已列在 `requirements.txt`
## a1/a2 微調 (δ/r 參數)
## systemd
針對二階濾波器 (2P1Z/2P2Z),UI 提供了進階的步階微調功能:
查看服務狀態:
- **δ (Delta)**:控制極點的頻率/阻尼平衡點。
- **r**:控制極點的半徑/頻寬。
- 公式:`a1 = -a0 - r + δ``a2 = r + δ` (定點數模式) 或 `a1 = -1 - r + δ``a2 = r + δ` (浮點數模式)。
- 支援透過 Shift + 滑鼠滾輪或觸控拖曳進行高精度微調。
```bash
sudo systemctl status dea.service
```
## CSV 上傳
啟動、停止、重啟
限制與行為
```bash
sudo systemctl start dea.service
sudo systemctl stop dea.service
sudo systemctl restart dea.service
```
- CSV 檔案大小上限為 32 MB。
- CSV 必須有標題列與至少一筆資料。
- 可選擇要濾波的訊號欄位;若第一欄像 `time`、`t`、`sec`、`x`、`index`,前端會預設選第二欄。
- 圖表回應最多回傳 5000 點,避免大型 CSV 讓前端過重。
- 下載 CSV 會保留完整資料,不套用圖表降採樣。
查看 log
專案根目錄的 `chirp_signal.csv` 是上傳測試範例,約 19 MB、500001 行,欄位為
```bash
sudo journalctl -u dea.service -f
```
若修改 `/etc/systemd/system/dea.service`
```bash
sudo systemctl daemon-reload
sudo systemctl restart dea.service
```text
time,value
```
## 測試與檢查
@@ -208,38 +195,25 @@ sudo systemctl restart dea.service
- API middleware 只允許 private / loopback IP 存取。
- 回應會加上 CSP、`Referrer-Policy`、`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`。
- CSP 的 script 來源允許本機與 Plotly CDN
```text
'self' https://cdn.plot.ly
```
- CSP 的 script 來源允許本機與 Plotly CDN
- 係數數量上限為 64。
- CSV 上限為 32 MB。
- 頻率、係數、`a[0]` 與 CSV 訊號欄位都有後端驗證。
## 常見問題
### 為什麼無法連線連接埠?
1. 確認是否使用 HTTPS 連線。
2. 確認瀏覽器是否支援 Web Serial API (推薦使用 Chrome/Edge)。
3. 檢查 USB 傳輸線是否連接正確。
### 頁面沒有更新
1. 瀏覽器按 `Ctrl+F5` 強制重新整理。
2. 確認前端 build 檔存在:
```bash
ls static/build/app.js static/build/app.css
```
3. 重新 build 並重啟服務:
```bash
npm run build
sudo systemctl restart dea.service
```
2. 執行 `npm run build` 重新建置前端。
3. 執行 `sudo systemctl restart dea.service`。
### systemd 顯示 `status=203/EXEC`
通常是 `.venv` 或 `uvicorn` 路徑不正確。可重建 venv
```bash
rm -rf .venv
python3 -m venv .venv
@@ -248,21 +222,7 @@ sudo systemctl restart dea.service
```
### 找不到 pytest
本專案不使用 pytest。請改用:
```bash
.venv/bin/python -m unittest discover -s tests
```
### MCU COM Port清單是空的
1. 確認 USB 裝置已插入。
2. 確認執行服務的使用者有 serial port 權限。
3. 檢查預設 port
```bash
echo $MCU_SERIAL_PORT
```
4. 可在頁面按「更新」重新讀取 serial ports。
Regular → Executable
+19 -19
View File
@@ -1,27 +1,27 @@
#!/usr/bin/env bash
# 產生自簽 SSL 憑證供 Uvicorn HTTPS 使用
# 用法: bash certs/generate_cert.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CERT_FILE="${SCRIPT_DIR}/cert.pem"
KEY_FILE="${SCRIPT_DIR}/key.pem"
#!/usr/bin/env bash
# 產生自簽 SSL 憑證供 Uvicorn HTTPS 使用
# 用法: bash certs/generate_cert.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CERT_FILE="${SCRIPT_DIR}/cert.pem"
KEY_FILE="${SCRIPT_DIR}/key.pem"
DAYS=3650
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
echo "憑證已存在: ${CERT_FILE}, ${KEY_FILE}"
echo "如需重新產生,請先刪除後再執行。"
exit 0
fi
openssl req -x509 -newkey rsa:2048 -nodes \
echo "憑證已存在: ${CERT_FILE}, ${KEY_FILE}"
echo "如需重新產生,請先刪除後再執行。"
exit 0
fi
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$KEY_FILE" \
-out "$CERT_FILE" \
-days "$DAYS" \
-subj "/CN=$(hostname)" \
-addext "subjectAltName=DNS:$(hostname),IP:0.0.0.0"
echo "已產生自簽憑證(有效 ${DAYS} 天):"
echo " cert: ${CERT_FILE}"
echo " key: ${KEY_FILE}"
echo "已產生自簽憑證(有效 ${DAYS} 天):"
echo " cert: ${CERT_FILE}"
echo " key: ${KEY_FILE}"
+5 -6
View File
@@ -28,20 +28,19 @@
<div
class="lg:hidden flex border-b border-slate-200 dark:border-gray-800 bg-white dark:bg-dark z-10 flex-shrink-0">
<button @click="mobileTab = 'settings'" :class="mobileTab === 'settings'
? 'border-b-2 role-border-primary role-text-primary role-bg-primary-soft'
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.592c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.655.85.11.052.218.108.324.168.325.183.72.177 1.039-.019l1.09-.67a1.125 1.125 0 011.45.12l1.833 1.833c.39.39.439.999.12 1.45l-.67 1.09c-.196.319-.202.714-.019 1.039.06.106.116.214.168.324.164.342.476.592.85.655l1.281.213c.542.09.94.56.94 1.11v2.592c0 .55-.398 1.02-.94 1.11l-1.281.213c-.374.063-.686.313-.85.655a6.47 6.47 0 01-.168.324c-.183.325-.177.72.019 1.039l.67 1.09c.319.451.27 1.06-.12 1.45l-1.833 1.833a1.125 1.125 0 01-1.45.12l-1.09-.67c-.319-.196-.714-.202-1.039-.019a6.47 6.47 0 01-.324.168c-.342.164-.592.476-.655.85l-.213 1.281c-.09.542-.56.94-1.11.94h-2.592c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.655-.85a6.47 6.47 0 01-.324-.168c-.325-.183-.72-.177-1.039.019l-1.09.67a1.125 1.125 0 01-1.45-.12L2.99 18.157a1.125 1.125 0 01-.12-1.45l.67-1.09c.196-.319.202-.714.019-1.039a6.47 6.47 0 01-.168-.324c-.164-.342-.476-.592-.85-.655l-1.281-.213A1.125 1.125 0 01.32 12.276V9.684c0-.55.398-1.02.94-1.11l1.281-.213c.374-.063.686-.313.85-.655.052-.11.108-.218.168-.324.183-.325.177-.72-.019-1.039l-.67-1.09a1.125 1.125 0 01.12-1.45L4.823 1.97a1.125 1.125 0 011.45-.12l1.09.67c.319.196.714.202 1.039.019.106-.06.214-.116.324-.168.342-.164.592-.476.655-.85l.213-1.281z"
transform="translate(0.32 0.5) scale(0.97)"></path>
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
設定
</button>
<button @click="mobileTab = 'chart'" :class="mobileTab === 'chart'
? 'border-b-2 role-border-primary role-text-primary role-bg-primary-soft'
? 'border-b-2 role-border-primary role-text-primary bg-transparent'
: 'text-slate-500 dark:text-gray-400 hover:text-slate-700 dark:hover:text-gray-200'"
class="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -666,7 +665,7 @@
</aside>
<!-- 右側主視窗 (RWD: 手機版=圖表頁籤全螢幕桌面版=右側主區) -->
<main :class="mobileTab === 'chart' ? 'flex bg-white dark:bg-[#0f0f0f] !p-0' : 'hidden'"
<main :class="mobileTab === 'chart' ? 'flex' : 'hidden'"
class="lg:flex flex-1 flex-col overflow-y-auto bg-slate-50 dark:bg-[#0f0f0f] lg:p-6 gap-0 lg:gap-6 custom-scrollbar relative transition-colors duration-300">
<div v-if="globalError"
class="absolute top-4 right-6 role-bg-error-soft border role-border-error role-text-error role-text-on-fill px-4 py-3 rounded-lg shadow-lg text-base z-50 flex items-center gap-2">
@@ -708,7 +707,7 @@
<!-- 時域分析 -->
<div
class="chart-panel bg-white dark:bg-white lg:dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-100 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
class="chart-panel bg-white dark:bg-dark lg:rounded-xl border-b lg:border border-slate-100 lg:border-slate-200 dark:border-gray-100 lg:dark:border-gray-800 lg:shadow-md p-0 lg:p-1 flex-shrink-0 relative transition-colors duration-300">
<div
class="chart-panel-header px-5 py-4 border-b border-slate-100 dark:border-gray-800 bg-white dark:bg-dark backdrop-blur-sm z-10 relative">
<h3 class="font-bold text-slate-800 dark:text-gray-200 tracking-wide flex items-center gap-2">
+9 -6
View File
@@ -683,14 +683,16 @@ export default {
},
resetFilterParams() {
clearTimeout(this.bodeTimeout);
const isManual = this.filterType === MANUAL_FILTER_TYPE;
this.fs = DEFAULT_FS;
this.filterType = DEFAULT_FILTER_TYPE;
this.systemGain = 1.0;
this.params = cloneDefaultParams();
this.b_str = DEFAULT_B_STR;
this.a_str = DEFAULT_A_STR;
this.baseB = this.padTo7(this.parseCoeffs(DEFAULT_B_STR));
this.baseA = this.padTo7(this.parseCoeffs(DEFAULT_A_STR));
if (isManual) {
this.b_str = DEFAULT_B_STR;
this.a_str = DEFAULT_A_STR;
this.baseB = this.padTo7(this.parseCoeffs(DEFAULT_B_STR));
this.baseA = this.padTo7(this.parseCoeffs(DEFAULT_A_STR));
}
this.clearFixedOverrides();
this.bSliders = zero7();
this.aSliders = zero7();
@@ -710,7 +712,8 @@ export default {
this.stopShiftBitDrag();
this.activeCoeffAdjustment = null;
this.globalError = null;
this.applyFilterDesign();
if (isManual) this.updateBodePlot({ switchToChart: false });
else this.applyFilterDesign();
},
async applyFilterDesign() {
if (this.filterType === MANUAL_FILTER_TYPE) return;
+1648 -1506
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long