a
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project overview
|
||||
|
||||
Urine test strip recognition system (尿检识别系统) — a full-stack web app that analyzes photos of medical urine reagent strips. Two analysis modes:
|
||||
|
||||
- **Image mode**: Upload a strip photo → OpenCV computer vision pipeline locates reagent pads and extracts color values in RGB/HSV/LAB.
|
||||
- **JSON data mode**: Upload pre-extracted color data → OpenCV Random Trees ML models infer semi-quantitative results (negative, +, ++, etc.) per analyte.
|
||||
|
||||
The system was ported from a C++ HarmonyOS app (`/home/ubuntu/nanocode/UrineCheck/`).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Backend (Python/FastAPI)
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn app:app --host 0.0.0.0 --port 8904
|
||||
# OR: python app.py
|
||||
|
||||
# Frontend (React/Vite)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # dev server on :8905, proxies /api → :8904
|
||||
npm run build # production build to dist/
|
||||
```
|
||||
|
||||
There are **no application-level tests** (no pytest, no Jest/Vitest).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → :8905 (Vite/React) ──/api──proxy──→ :8904 (FastAPI/Uvicorn)
|
||||
│
|
||||
SSO Center (:9000) ←── JWT cookie verification
|
||||
```
|
||||
|
||||
### Backend (`backend/`)
|
||||
|
||||
- **`app.py`** — FastAPI entry point. Creates SQLite tables on startup, registers `auth` and `urinecheck` routers, CORS configured for localhost + `129.211.173.87`.
|
||||
- **`routers/auth.py`** — Dual-mode auth: local username/password (JWT bearer token, 30-day expiry, stored in localStorage) and SSO cookie validation (decodes JWT from `access_token` cookie using a shared secret). Default admin `admin`/`admin123` created on first startup.
|
||||
- **`routers/urinecheck.py`** — Two endpoints: `POST /api/urinecheck/analyze` (multipart image upload → CV recognition) and `POST /api/urinecheck/analyze-json` (JSON body → ML inference).
|
||||
- **`database.py`** / **`models.py`** — SQLAlchemy + SQLite. Single `admins` table (id, username, password_hash, created_at). Database file: `backend/urinecheck.db`.
|
||||
- **`schemas.py`** — Pydantic request/response models for both endpoints.
|
||||
- **`sso_client.py`** — Reads `access_token` cookie, decodes JWT locally with a shared secret key. Validates against external SSO at `http://129.211.173.87:9000`.
|
||||
|
||||
### Two recognition pipelines
|
||||
|
||||
#### Image-based CV pipeline (`urine_recognizer.py`, ~1180 lines — the largest file)
|
||||
|
||||
`UrineStripRecognizer` class implements a 7-step algorithm:
|
||||
1. Preprocess (BGR/HSV/Gray conversion, Gaussian blur)
|
||||
2. Detect locator blocks (HSV color segmentation, contour finding with Canny fallback)
|
||||
3. Filter candidates (area, aspect ratio, rectangularity, Y-clustering, pair validation by color + RGB distance + area ratio)
|
||||
4. Blur assessment (Laplacian variance → 3 sharpness levels)
|
||||
5. Spec matching (distance ratio + area ratio → matches one of 6 PaperConfigs)
|
||||
6. Calculate color block centers (edge-to-edge interpolation)
|
||||
7. Read colors (white balance, circular ROI sampling → RGB/HSV/LAB output)
|
||||
|
||||
`PaperConfig` holds the geometric layout for 6 strip specifications (8/11/14 pads × S-type 10mm / L-type 15mm locators). `ColorReader` handles white balancing and color-space extraction.
|
||||
|
||||
#### JSON-based ML pipeline (`json_recognizer.py`, ~430 lines)
|
||||
|
||||
`UrineJsonRecognizer` class:
|
||||
- Loads OpenCV RTrees models from `/usr/local/Models/*.data`
|
||||
- Processes multi-frame data and takes majority vote across frames
|
||||
- Applies analyte-specific result index corrections (14 correction functions per strip color type)
|
||||
- Enforces PRO/MAU and SG/pH cross-correlation rules
|
||||
|
||||
### ML model configuration (`ml_config.py`)
|
||||
|
||||
`MLConfigLoader` singleton parses `/usr/local/Models/ml.json` which defines model files, positions, algorithms, and result index maps for 21 paper types × 15 positions.
|
||||
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
- **React 18 + React Router 6 + Vite 5 + Tailwind CSS 3**
|
||||
- `App.jsx` — Route definitions with `PrivateRoute` auth wrapper.
|
||||
- `pages/Login.jsx` — Local auth form + SSO redirect support.
|
||||
- `pages/UrineCheck.jsx` — Image upload + results display.
|
||||
- `pages/JsonDataCheck.jsx` — JSON/paste upload + ML results table.
|
||||
- `api/config.js` — Axios instance configured with `baseURL` from `import.meta.env.VITE_API_BASE_URL` (falls back to current origin).
|
||||
|
||||
## Ports
|
||||
|
||||
| Service | Port |
|
||||
|---------|------|
|
||||
| Frontend (Vite dev) | 8905 |
|
||||
| Backend (Uvicorn) | 8904 |
|
||||
| SSO Center | 9000 |
|
||||
|
||||
Frontend dev server proxies `/api/*` to `http://localhost:8904`.
|
||||
|
||||
## Deployment
|
||||
|
||||
systemd units in `deploy/`:
|
||||
- `urinecheck-backend.service` — runs uvicorn as user `ubuntu` from `backend/`
|
||||
- `urinecheck-frontend.service` — runs `npm run dev` as user `ubuntu` from `frontend/`
|
||||
|
||||
The ML model files (`/usr/local/Models/ml.json` and `/usr/local/Models/*.data`) must be present on the server — they are not in this repository.
|
||||
|
||||
## Key dependencies
|
||||
|
||||
Backend: `fastapi`, `uvicorn`, `sqlalchemy` (SQLite), `python-jose` (JWT), `passlib[bcrypt]`, `opencv-python-headless`, `numpy`
|
||||
Frontend: `react` 18, `react-router-dom` 6, `axios`, `vite` 5, `tailwindcss` 3, `preline` (UI component library)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
尿检识别系统后端 - FastAPI
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from database import engine, Base, SessionLocal
|
||||
from routers import auth, urinecheck
|
||||
from routers.auth import init_admin
|
||||
|
||||
# 创建数据库表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 初始化默认管理员
|
||||
db = SessionLocal()
|
||||
init_admin(db)
|
||||
db.close()
|
||||
|
||||
app = FastAPI(
|
||||
title="尿检识别系统 API",
|
||||
description="尿检试纸颜色识别后端API",
|
||||
version="1.0.0",
|
||||
redirect_slashes=False
|
||||
)
|
||||
|
||||
# CORS配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:8905",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:8905",
|
||||
"http://0.0.0.0:8905",
|
||||
"http://129.211.173.87:8905", # 尿检前端
|
||||
"http://129.211.173.87:9000", # SSO 中心
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(urinecheck.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"message": "尿检识别系统 API",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8904)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
数据库配置 - SQLite
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# 数据库路径
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATABASE_URL = f"sqlite:///{os.path.join(BASE_DIR, 'urinecheck.db')}"
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# 依赖注入
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
JSON数据尿检识别器 — 直接翻译 C++ ProcessJsonDataRequest + Get14Results
|
||||
|
||||
完整复制:
|
||||
- UrineCheck_Class::ProcessJsonDataRequest (urinecheck_class.cpp:2052)
|
||||
- UrineCheck14::Get14Results(mapData, X) (UrineCheck14.cpp:1041)
|
||||
- 所有 Red*ResultIndex 修正函数 (UrineCheck14.cpp:1413-1746)
|
||||
- PRO/MAU + SG/PH 关联修正逻辑
|
||||
|
||||
Author: 小月
|
||||
Date: 2026-05-13
|
||||
"""
|
||||
|
||||
import math
|
||||
import logging
|
||||
from collections import Counter
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger("json_recognizer")
|
||||
|
||||
# 延迟导入(避免循环依赖)
|
||||
_ml_config = None
|
||||
|
||||
|
||||
def _get_config():
|
||||
global _ml_config
|
||||
if _ml_config is None:
|
||||
from ml_config import MLConfigLoader
|
||||
_ml_config = MLConfigLoader()
|
||||
_ml_config.load()
|
||||
return _ml_config
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 项目类型映射 (C++ getProjectType)
|
||||
# =============================================================================
|
||||
|
||||
def get_project_type(paper_type: int) -> int:
|
||||
"""paperType → projectType 映射"""
|
||||
mapping = {
|
||||
1: 10,
|
||||
2: 2, 5: 2,
|
||||
3: 1, 4: 1,
|
||||
6: 9, 7: 9, 8: 9, 9: 9,
|
||||
10: 7, 11: 7, 12: 7, 13: 7,
|
||||
14: 8, 15: 8, 16: 8, 17: 8, 18: 8, 19: 8,
|
||||
20: 20,
|
||||
}
|
||||
return mapping.get(paper_type, 0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 多帧投票 (C++ CountResultsModal)
|
||||
# =============================================================================
|
||||
|
||||
def count_results_modal(results: Dict[int, List[int]]) -> Dict[int, int]:
|
||||
"""对每个 position,取多帧结果中出现次数最多的 index(众数)"""
|
||||
modal = {}
|
||||
for position, indices in results.items():
|
||||
if not indices:
|
||||
continue
|
||||
counter = Counter(indices)
|
||||
most_common = counter.most_common(1)[0][0]
|
||||
modal[position] = most_common
|
||||
return modal
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 结果索引修正函数 (红色试纸, algorithm=0)
|
||||
# 直接翻译 C++ UrineCheck14::GetRed*ResultIndex
|
||||
# =============================================================================
|
||||
|
||||
def _safe_round(val) -> int:
|
||||
"""safe round like C++ round()"""
|
||||
if isinstance(val, np.ndarray):
|
||||
val = float(val.item()) if val.size == 1 else float(val[0])
|
||||
return int(round(float(val)))
|
||||
|
||||
|
||||
def red_glu_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_ket_index(hsv_lab, p) -> int:
|
||||
ret = _safe_round(p)
|
||||
h = float(hsv_lab[0])
|
||||
if ret > 1 and 45 < h < 145:
|
||||
return 0
|
||||
return ret
|
||||
|
||||
def red_bil_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_pro_index(hsv_lab, p) -> int:
|
||||
ret = _safe_round(p)
|
||||
h = float(hsv_lab[0])
|
||||
if ret > 0 and (h < 25 or h > 110):
|
||||
return 0
|
||||
return ret
|
||||
|
||||
def red_nit_index(hsv_lab, p) -> int:
|
||||
ret = _safe_round(p)
|
||||
h = float(hsv_lab[0])
|
||||
# C++ 20230731 version: h>40 && h<140
|
||||
if ret > 0 and 40 < h < 140:
|
||||
return 0
|
||||
return ret
|
||||
|
||||
def red_ph_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_bld_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_sg_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_wbc_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_vc_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_cre_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_uca_index(hsv_lab, p) -> int:
|
||||
return _safe_round(p)
|
||||
|
||||
def red_mau_index(hsv_lab, p) -> int:
|
||||
ret = _safe_round(p)
|
||||
h = float(hsv_lab[0])
|
||||
if ret > 0 and (h < 30 or h > 115):
|
||||
return 0
|
||||
return ret
|
||||
|
||||
def red_uro_index(hsv_lab, p) -> int:
|
||||
ret = _safe_round(p)
|
||||
h = float(hsv_lab[0])
|
||||
if ret > 0 and 25 < h < 150:
|
||||
return 0
|
||||
return ret
|
||||
|
||||
# ---- 绿色/蓝色试纸 (algorithm=1) ----
|
||||
def glu_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def ket_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def bil_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def pro_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def nit_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def ph_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def bld_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def sg_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def wbc_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def vc_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def cre_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def uca_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def mau_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def uro_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
|
||||
# ---- PET试纸 (algorithm=2) ----
|
||||
def pet_glu_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
def pet_sg_index(hsv_lab, p) -> int: return _safe_round(p)
|
||||
|
||||
|
||||
# 函数名 → 函数对象映射
|
||||
RESULT_FUNC_REGISTRY = {
|
||||
# Red (algorithm=0)
|
||||
"red_glu": red_glu_index, "red_ket": red_ket_index,
|
||||
"red_bil": red_bil_index, "red_pro": red_pro_index,
|
||||
"red_nit": red_nit_index, "red_ph": red_ph_index,
|
||||
"red_bld": red_bld_index, "red_sg": red_sg_index,
|
||||
"red_wbc": red_wbc_index, "red_vc": red_vc_index,
|
||||
"red_cre": red_cre_index, "red_uca": red_uca_index,
|
||||
"red_mau": red_mau_index, "red_uro": red_uro_index,
|
||||
# Green/Blue (algorithm=1)
|
||||
"glu": glu_index, "ket": ket_index, "bil": bil_index,
|
||||
"pro": pro_index, "nit": nit_index, "ph": ph_index,
|
||||
"bld": bld_index, "sg": sg_index, "wbc": wbc_index,
|
||||
"vc": vc_index, "cre": cre_index, "uca": uca_index,
|
||||
"mau": mau_index, "uro": uro_index,
|
||||
# PET (algorithm=2)
|
||||
"pet_glu": pet_glu_index, "pet_sg": pet_sg_index,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 核心识别器
|
||||
# =============================================================================
|
||||
|
||||
class UrineJsonRecognizer:
|
||||
"""JSON数据尿检识别器"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = _get_config()
|
||||
|
||||
def recognize(self, json_body: dict) -> dict:
|
||||
"""
|
||||
完整识别流程 — 翻译 ProcessJsonDataRequest
|
||||
|
||||
输入: 与 txt 文件格式一致的 JSON dict
|
||||
输出: {"score": "100", "status": "0", "data": [{id, index}, ...]}
|
||||
"""
|
||||
config = self.config
|
||||
|
||||
# ---- 1. 提取字段 ----
|
||||
data_array = json_body.get("data", [])
|
||||
extend = json_body.get("extend", {})
|
||||
|
||||
if not data_array or len(data_array) == 0:
|
||||
return {
|
||||
"score": "0", "status": "-8",
|
||||
"message": "数据为空!", "project_id": 0, "imgid": 0,
|
||||
}
|
||||
|
||||
# ---- 2. 确定试纸类型 ----
|
||||
paper_type = self._extract_paper_type(extend)
|
||||
project_id = get_project_type(paper_type)
|
||||
|
||||
if paper_type < 1 or paper_type >= 21:
|
||||
return {
|
||||
"score": "0", "status": "-10",
|
||||
"message": "设置试纸类型参数错误!",
|
||||
"project_id": paper_type, "imgid": 0,
|
||||
}
|
||||
|
||||
X = paper_type - 1 # C++ 中的 papertypeIndex
|
||||
|
||||
# ---- 3. 逐帧处理 ----
|
||||
results: Dict[int, List[int]] = {}
|
||||
|
||||
for frame in data_array:
|
||||
if not isinstance(frame, list):
|
||||
continue
|
||||
|
||||
# 3a. 解析颜色数据 → mapData
|
||||
map_data = {}
|
||||
for item in frame:
|
||||
item_id = int(item.get("id", 0))
|
||||
value_str = str(item.get("value", ""))
|
||||
try:
|
||||
values = [float(x) for x in value_str.split(",")]
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if len(values) >= 6:
|
||||
map_data[item_id] = values[:6]
|
||||
|
||||
if not map_data:
|
||||
continue
|
||||
|
||||
# 3b. 调用 ML 推理
|
||||
frame_result = self._get14_results(map_data, X)
|
||||
|
||||
# 3c. 收集结果
|
||||
for position, raw_index in frame_result.items():
|
||||
if position not in results:
|
||||
results[position] = []
|
||||
results[position].append(raw_index)
|
||||
|
||||
if not results:
|
||||
return {
|
||||
"score": "0", "status": "-8",
|
||||
"message": "未能识别任何色块!", "project_id": project_id, "imgid": 0,
|
||||
}
|
||||
|
||||
# ---- 4. 多帧投票 ----
|
||||
last_result = count_results_modal(results)
|
||||
|
||||
# ---- 5. PRO/MAU 关联修正 ----
|
||||
pro_pos = config.positionsPro[X]
|
||||
mau_pos = config.positionsMau[X]
|
||||
pro_val = -1
|
||||
mau_val = -1
|
||||
max_mau = config.get_max_mau(X)
|
||||
|
||||
if pro_pos >= 0 and mau_pos >= 0:
|
||||
if pro_pos in last_result:
|
||||
pro_val = last_result[pro_pos]
|
||||
if mau_pos in last_result:
|
||||
mau_val = last_result[mau_pos]
|
||||
|
||||
if mau_val == 0:
|
||||
pro_val = 0
|
||||
else:
|
||||
if pro_val == 0 and mau_val == max_mau:
|
||||
mau_val = max_mau - 1
|
||||
if pro_val >= 1:
|
||||
mau_val = max_mau
|
||||
|
||||
# ---- 6. SG/PH 关联修正 ----
|
||||
sg_pos = config.positionsSg[X]
|
||||
ph_pos = config.positionsPh[X]
|
||||
sg_val = -1
|
||||
ph_val = -1
|
||||
max_sg = config.get_max_sg(X)
|
||||
|
||||
if sg_pos >= 0 and ph_pos >= 0:
|
||||
if sg_pos in last_result:
|
||||
sg_val = last_result[sg_pos]
|
||||
if ph_pos in last_result:
|
||||
ph_val = last_result[ph_pos]
|
||||
|
||||
if project_id < 20 and ph_val >= 2 and sg_val < max_sg:
|
||||
sg_val += 1
|
||||
|
||||
# ---- 7. 构建输出 data 数组 ----
|
||||
output_data = []
|
||||
for position in sorted(last_result.keys()):
|
||||
raw_index = last_result[position]
|
||||
output_id = config.get_stat_id(X, position)
|
||||
|
||||
# 获取最终 index
|
||||
if pro_pos >= 0 and mau_pos >= 0:
|
||||
if position == pro_pos:
|
||||
raw_index = pro_val
|
||||
elif position == mau_pos:
|
||||
raw_index = mau_val
|
||||
|
||||
if sg_pos >= 0 and ph_pos >= 0:
|
||||
if position == sg_pos:
|
||||
raw_index = sg_val
|
||||
|
||||
# 通过 indexmap 映射
|
||||
output_index = config.get_output_index(X, position, raw_index)
|
||||
|
||||
output_data.append({
|
||||
"id": output_id,
|
||||
"index": output_index,
|
||||
})
|
||||
|
||||
return {
|
||||
"score": "100",
|
||||
"status": "0",
|
||||
"message": "OK",
|
||||
"project_id": project_id,
|
||||
"paper_id": paper_type,
|
||||
"imgid": 0,
|
||||
"data": output_data,
|
||||
}
|
||||
|
||||
# ========== ML 推理 ==========
|
||||
|
||||
def _get14_results(self, map_data: Dict[int, List[float]],
|
||||
paper_type: int) -> Dict[int, int]:
|
||||
"""
|
||||
完整翻译 UrineCheck14::Get14Results(mapData, X)
|
||||
|
||||
对每个模型执行: 取6维特征 → RTrees预测 → 结果索引函数修正
|
||||
"""
|
||||
config = self.config
|
||||
results = {}
|
||||
|
||||
for position, model in config.models[paper_type].items():
|
||||
# 查找该 position 的颜色数据
|
||||
if position not in map_data:
|
||||
continue
|
||||
|
||||
vdata = map_data[position]
|
||||
if len(vdata) < 6:
|
||||
continue
|
||||
|
||||
# 构造 1×6 样本矩阵
|
||||
sample = np.array([vdata[:6]], dtype=np.float32)
|
||||
|
||||
# RTrees 预测
|
||||
try:
|
||||
resp = model.predict(sample)
|
||||
# resp 可能是 (retval, results) 元组或直接值
|
||||
if isinstance(resp, tuple):
|
||||
p = float(resp[1].item()) if resp[1].size == 1 else float(resp[1][0])
|
||||
else:
|
||||
p = float(resp)
|
||||
except Exception as e:
|
||||
logger.warning(f"预测失败 paperType={paper_type} pos={position}: {e}")
|
||||
continue
|
||||
|
||||
# 获取结果索引函数
|
||||
func_type = config.get_func_type(paper_type, position)
|
||||
if func_type is None:
|
||||
continue
|
||||
|
||||
func = RESULT_FUNC_REGISTRY.get(func_type)
|
||||
if func is None:
|
||||
continue
|
||||
|
||||
# 调用结果索引函数
|
||||
try:
|
||||
pi = func(vdata[:6], p)
|
||||
results[position] = pi
|
||||
except Exception as e:
|
||||
logger.warning(f"索引函数调用失败 {func_type}: {e}")
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
# ========== 试纸类型提取 ==========
|
||||
|
||||
def _extract_paper_type(self, extend: dict) -> int:
|
||||
"""从 extend 字段提取 paperType"""
|
||||
if not isinstance(extend, dict) or not extend:
|
||||
return 0
|
||||
|
||||
# type 优先
|
||||
if "type" in extend:
|
||||
try:
|
||||
return int(extend["type"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# color_14i 其次
|
||||
if "color_14i" in extend:
|
||||
try:
|
||||
return int(extend["color_14i"])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 全局单例
|
||||
# =============================================================================
|
||||
|
||||
_recognizer: Optional[UrineJsonRecognizer] = None
|
||||
|
||||
|
||||
def get_recognizer() -> UrineJsonRecognizer:
|
||||
global _recognizer
|
||||
if _recognizer is None:
|
||||
_recognizer = UrineJsonRecognizer()
|
||||
return _recognizer
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
ML配置加载器 — 解析 /usr/local/Models/ml.json
|
||||
直接翻译 C++ UrineCheck14::initconfig() + UrineCheck_Class::initconfig()
|
||||
|
||||
Author: 小月
|
||||
Date: 2026-05-13
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Callable, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger("ml_config")
|
||||
|
||||
# 常量(与C++一致)
|
||||
MAX_PAPER_TYPE = 21
|
||||
MAX_PAPER_BLOCK = 15
|
||||
MAX_ALG_TAG = 8
|
||||
|
||||
# 各检测项在 ml.json 中的 name → 结果索引函数类型映射
|
||||
# algorithm=0 → Red, algorithm=1 → Green/Blue (原始)
|
||||
ITEM_FUNC_MAP = {
|
||||
"uro": "uro",
|
||||
"glu": "glu",
|
||||
"ket": "ket",
|
||||
"bil": "bil",
|
||||
"pro": "pro",
|
||||
"nit": "nit",
|
||||
"ph": "ph",
|
||||
"bld": "bld",
|
||||
"sg": "sg",
|
||||
"wbc": "wbc",
|
||||
"vc": "vc",
|
||||
"cre": "cre",
|
||||
"uca": "uca",
|
||||
"mau": "mau",
|
||||
}
|
||||
|
||||
|
||||
class MLConfigLoader:
|
||||
"""ML模型配置加载器 — 全局单例"""
|
||||
|
||||
def __init__(self, config_path: str = "/usr/local/Models/ml.json"):
|
||||
self.config_path = config_path
|
||||
|
||||
# 三维查表: [paperType][position][rawIndex] → outputIndex
|
||||
self.itemsResultIdxMapTbl: List[List[List[int]]] = [
|
||||
[[-1] * MAX_ALG_TAG for _ in range(MAX_PAPER_BLOCK)]
|
||||
for _ in range(MAX_PAPER_TYPE)
|
||||
]
|
||||
|
||||
# 二维: [paperType][position] → 输出id
|
||||
self.itemsStatIds: List[List[int]] = [
|
||||
[-1] * MAX_PAPER_BLOCK for _ in range(MAX_PAPER_TYPE)
|
||||
]
|
||||
|
||||
# [paperType][position] → 已加载的 OpenCV RTrees 模型
|
||||
self.models: List[Dict[int, cv2.ml.RTrees]] = [
|
||||
{} for _ in range(MAX_PAPER_TYPE)
|
||||
]
|
||||
|
||||
# [paperType][position] → 结果索引函数类型名
|
||||
# "uro"|"glu"|...|"red_uro"|"red_glu"|...|"pet_glu"|"pet_sg"
|
||||
self.result_funcs: List[Dict[int, str]] = [
|
||||
{} for _ in range(MAX_PAPER_TYPE)
|
||||
]
|
||||
|
||||
# PRO/MAU/SG/pH 在各 paperType 上的 position
|
||||
self.positionsPro: List[int] = [-1] * MAX_PAPER_TYPE
|
||||
self.positionsMau: List[int] = [-1] * MAX_PAPER_TYPE
|
||||
self.positionsSg: List[int] = [-1] * MAX_PAPER_TYPE
|
||||
self.positionsPh: List[int] = [-1] * MAX_PAPER_TYPE
|
||||
|
||||
# 文件名缓存(避免重复加载同一个 .data 文件)
|
||||
self._file_cache: Dict[str, cv2.ml.RTrees] = {}
|
||||
|
||||
self._loaded = False
|
||||
|
||||
def load(self) -> bool:
|
||||
"""加载 ml.json 并初始化所有配置"""
|
||||
if self._loaded:
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(self.config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"无法加载 {self.config_path}: {e}")
|
||||
return False
|
||||
|
||||
ml = config.get("ml", [])
|
||||
if not ml:
|
||||
logger.error("ml.json 中未找到 'ml' 数组")
|
||||
return False
|
||||
|
||||
for item in ml:
|
||||
name = item.get("name", "")
|
||||
models_list = item.get("model", [])
|
||||
|
||||
if name not in ITEM_FUNC_MAP:
|
||||
logger.warning(f"未知检测项目: {name}")
|
||||
continue
|
||||
|
||||
for paper_type, block in enumerate(models_list):
|
||||
if paper_type >= MAX_PAPER_TYPE:
|
||||
break
|
||||
|
||||
position = block.get("positon", -1)
|
||||
if position < 0:
|
||||
continue
|
||||
|
||||
item_id = block.get("id", 0)
|
||||
filepath = block.get("filepath", "")
|
||||
algorithm = block.get("algorithm", 1)
|
||||
indexmap_str = block.get("indexmap", "0")
|
||||
|
||||
# 记录 PRO/MAU/SG/pH 的 position
|
||||
if name == "pro":
|
||||
self.positionsPro[paper_type] = position
|
||||
elif name == "mau":
|
||||
self.positionsMau[paper_type] = position
|
||||
elif name == "sg":
|
||||
self.positionsSg[paper_type] = position
|
||||
elif name == "ph":
|
||||
self.positionsPh[paper_type] = position
|
||||
|
||||
# 记录 output id
|
||||
self.itemsStatIds[paper_type][position] = item_id
|
||||
|
||||
# 解析 indexmap
|
||||
index_map = [int(x.strip()) for x in indexmap_str.split(",")]
|
||||
for k, val in enumerate(index_map):
|
||||
if k < MAX_ALG_TAG:
|
||||
self.itemsResultIdxMapTbl[paper_type][position][k] = val
|
||||
|
||||
# 加载模型文件(带缓存)
|
||||
if filepath not in self._file_cache:
|
||||
try:
|
||||
model = cv2.ml.RTrees_load(filepath)
|
||||
if model is None or model.empty():
|
||||
logger.error(f"模型加载失败: {filepath}")
|
||||
continue
|
||||
self._file_cache[filepath] = model
|
||||
except Exception as e:
|
||||
logger.error(f"模型加载异常 {filepath}: {e}")
|
||||
continue
|
||||
|
||||
self.models[paper_type][position] = self._file_cache[filepath]
|
||||
|
||||
# 确定结果索引函数
|
||||
func_type = self._resolve_func_type(name, algorithm)
|
||||
self.result_funcs[paper_type][position] = func_type
|
||||
|
||||
self._loaded = True
|
||||
count = sum(len(m) for m in self.models)
|
||||
logger.info(f"ML配置加载完成: {count} 个模型, {len(self._file_cache)} 个唯一文件")
|
||||
return True
|
||||
|
||||
def _resolve_func_type(self, name: str, algorithm: int) -> str:
|
||||
"""根据试纸类型确定结果索引函数名"""
|
||||
base = ITEM_FUNC_MAP[name]
|
||||
if algorithm == 0:
|
||||
# 红色试纸
|
||||
return f"red_{base}"
|
||||
elif algorithm == 2:
|
||||
# PET 试纸 (仅 glu/sg 有)
|
||||
return f"pet_{base}"
|
||||
else:
|
||||
# 绿色/蓝色试纸(原始)
|
||||
return base
|
||||
|
||||
# ---- 便捷查询方法 ----
|
||||
|
||||
def get_model(self, paper_type: int, position: int) -> Optional[cv2.ml.RTrees]:
|
||||
if paper_type < 0 or paper_type >= MAX_PAPER_TYPE:
|
||||
return None
|
||||
return self.models[paper_type].get(position)
|
||||
|
||||
def get_func_type(self, paper_type: int, position: int) -> Optional[str]:
|
||||
if paper_type < 0 or paper_type >= MAX_PAPER_TYPE:
|
||||
return None
|
||||
return self.result_funcs[paper_type].get(position)
|
||||
|
||||
def get_stat_id(self, paper_type: int, position: int) -> int:
|
||||
if paper_type < 0 or paper_type >= MAX_PAPER_TYPE:
|
||||
return -1
|
||||
return self.itemsStatIds[paper_type][position]
|
||||
|
||||
def get_output_index(self, paper_type: int, position: int, raw_index: int) -> int:
|
||||
"""通过 indexmap 将算法输出index映射为最终输出index"""
|
||||
if paper_type < 0 or paper_type >= MAX_PAPER_TYPE:
|
||||
return 0
|
||||
if position < 0 or position >= MAX_PAPER_BLOCK:
|
||||
return 0
|
||||
if raw_index < 0 or raw_index >= MAX_ALG_TAG:
|
||||
return 0
|
||||
mapped = self.itemsResultIdxMapTbl[paper_type][position][raw_index]
|
||||
return mapped if mapped >= 0 else 0
|
||||
|
||||
def get_max_mau(self, paper_type: int) -> int:
|
||||
"""MAU最大等级: red=3, others=2"""
|
||||
return 2 if paper_type == 0 else 3
|
||||
|
||||
def get_max_sg(self, paper_type: int) -> int:
|
||||
"""SG最大等级: red=6, others=5"""
|
||||
return 5 if paper_type == 0 else 6
|
||||
|
||||
def has_model_at(self, paper_type: int, position: int) -> bool:
|
||||
if paper_type < 0 or paper_type >= MAX_PAPER_TYPE:
|
||||
return False
|
||||
return position in self.models[paper_type]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
数据库模型 - 仅管理员
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from datetime import datetime
|
||||
from database import Base
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
"""管理员账号"""
|
||||
__tablename__ = "admins"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, index=True, default="admin")
|
||||
password_hash = Column(String)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.18
|
||||
opencv-python-headless==4.10.0.84
|
||||
numpy==1.26.4
|
||||
@@ -0,0 +1 @@
|
||||
from . import auth, urinecheck
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
认证路由
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import get_db
|
||||
from models import Admin
|
||||
from schemas import LoginRequest, LoginResponse, PasswordChange
|
||||
from sso_client import get_current_user_from_cookie
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
# 安全配置
|
||||
SECRET_KEY = "urinecheck-secret-key-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 30
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
# 与 SSO 中心共享的 Cookie 属性(删除 Cookie 时需一致)
|
||||
SSO_COOKIE_DOMAIN = "129.211.173.87"
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
"""验证密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
"""生成密码哈希"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta = None):
|
||||
"""创建JWT令牌"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
async def get_current_admin(
|
||||
request: Request,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取当前登录管理员 —— 双模式认证
|
||||
1. 优先读取 Authorization Header(原有本地登录)
|
||||
2. 无 Header 则读取 Cookie 中的 SSO token,本地验签后查本地 Admin 表
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无法验证凭据",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
username = None
|
||||
|
||||
# 模式一:原有 Bearer Token
|
||||
if token:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
except JWTError:
|
||||
pass
|
||||
|
||||
# 模式二:SSO Cookie
|
||||
if not username:
|
||||
sso_user = get_current_user_from_cookie(request)
|
||||
if sso_user:
|
||||
username = sso_user.get("username")
|
||||
|
||||
if not username:
|
||||
raise credentials_exception
|
||||
|
||||
admin = db.query(Admin).filter(Admin.username == username).first()
|
||||
if admin is None:
|
||||
raise credentials_exception
|
||||
return admin
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(login_data: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""管理员登录"""
|
||||
admin = db.query(Admin).filter(Admin.username == login_data.username).first()
|
||||
|
||||
if not admin or not verify_password(login_data.password, admin.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误"
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": admin.username})
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
password_data: PasswordChange,
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""修改密码"""
|
||||
# 验证原密码
|
||||
if not verify_password(password_data.old_password, current_admin.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="原密码错误"
|
||||
)
|
||||
|
||||
# 更新密码
|
||||
current_admin.password_hash = get_password_hash(password_data.new_password)
|
||||
db.commit()
|
||||
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_me(current_admin: Admin = Depends(get_current_admin)):
|
||||
"""获取当前管理员信息"""
|
||||
return {"username": current_admin.username}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(response: Response):
|
||||
"""退出登录:清除本地 token 状态并删除 SSO Cookie"""
|
||||
response.delete_cookie("access_token", domain=SSO_COOKIE_DOMAIN, path="/")
|
||||
return {"message": "退出成功"}
|
||||
|
||||
|
||||
def init_admin(db: Session):
|
||||
"""初始化默认管理员账号"""
|
||||
admin = db.query(Admin).filter(Admin.username == "admin").first()
|
||||
if not admin:
|
||||
admin = Admin(
|
||||
username="admin",
|
||||
password_hash=get_password_hash("admin123")
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
print("✅ 默认管理员账号已创建: admin / admin123")
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
尿检试纸识别 API 路由
|
||||
POST /api/urinecheck/analyze - 上传试纸图片并识别
|
||||
POST /api/urinecheck/analyze-json - JSON数据格式识别
|
||||
"""
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
import tempfile
|
||||
import os
|
||||
import sys
|
||||
import json as json_lib
|
||||
|
||||
# 导入核心算法
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from urine_recognizer import analyze_urine_strip
|
||||
from json_recognizer import get_recognizer
|
||||
from schemas import JsonDataRequest
|
||||
|
||||
router = APIRouter(prefix="/api/urinecheck", tags=["尿检识别"])
|
||||
|
||||
# 允许的图片格式
|
||||
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png"}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_image(
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""
|
||||
上传尿检试纸图片并分析颜色
|
||||
|
||||
- 支持格式: JPG, PNG
|
||||
- 最大文件大小: 10MB
|
||||
- 支持规格: 8联/11联/14联 (S型/L型)
|
||||
"""
|
||||
# 验证文件类型
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的文件格式: {file.content_type},仅支持 JPG 和 PNG"
|
||||
)
|
||||
|
||||
# 读取文件内容
|
||||
image_data = await file.read()
|
||||
|
||||
# 检查文件大小
|
||||
if len(image_data) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"文件大小超过10MB限制 (当前: {len(image_data) / (1024*1024):.1f}MB)"
|
||||
)
|
||||
|
||||
if len(image_data) == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="上传的文件为空"
|
||||
)
|
||||
|
||||
# 保存到临时文件
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=os.path.splitext(file.filename or "image.jpg")[1],
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp.write(image_data)
|
||||
tmp_path = tmp.name
|
||||
|
||||
# 执行识别
|
||||
result = analyze_urine_strip(image_data)
|
||||
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/analyze-json")
|
||||
async def analyze_json(
|
||||
request: JsonDataRequest,
|
||||
):
|
||||
"""
|
||||
JSON数据格式尿检识别
|
||||
|
||||
输入格式与客户端上传的txt文件一致:
|
||||
```json
|
||||
{
|
||||
"extend": {"color_14i": "2"},
|
||||
"data": [
|
||||
[{"id": 0, "value": "7.62,146.43,..."}, ...],
|
||||
[{"id": 0, "value": "6.75,139.44,..."}, ...]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- value: "H,S,V,L,A,B" 6个颜色通道值
|
||||
- data: 多帧颜色数据(通常2-5帧)
|
||||
- extend.color_14i: 试纸类型 (1=绿, 2=红, 3=蓝4联, 5=蓝14联)
|
||||
"""
|
||||
recognizer = get_recognizer()
|
||||
|
||||
# 转换为dict传给识别器
|
||||
body = json_lib.loads(request.model_dump_json())
|
||||
|
||||
result = recognizer.recognize(body)
|
||||
return result
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Pydantic 数据验证模型
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
old_password: str
|
||||
new_password: str = Field(..., min_length=8, max_length=20)
|
||||
|
||||
|
||||
class JsonColorItem(BaseModel):
|
||||
"""单个色块的颜色数据"""
|
||||
id: int
|
||||
value: str
|
||||
|
||||
|
||||
class JsonDataRequest(BaseModel):
|
||||
"""JSON数据尿检请求 - 与txt文件格式一致"""
|
||||
extend: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
||||
data: List[List[JsonColorItem]] = Field(default_factory=list)
|
||||
project_id: Optional[int] = None
|
||||
@@ -0,0 +1,22 @@
|
||||
"""SSO Cookie 本地验签 —— 与 SSO 中心共享 SECRET_KEY"""
|
||||
from fastapi import Request, HTTPException
|
||||
from jose import jwt, JWTError
|
||||
|
||||
# 必须与 SSO 中心 sso_config.py 完全一致
|
||||
SECRET_KEY = "your-secret-key-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def get_current_user_from_cookie(request: Request) -> dict:
|
||||
"""从请求 Cookie 中读取 access_token,本地验签后返回用户信息"""
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if not username:
|
||||
return None
|
||||
return {"username": username}
|
||||
except JWTError:
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=尿检识别系统 Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/nanocode/urinecheck/backend
|
||||
ExecStart=/usr/bin/python3 -m uvicorn app:app --host 0.0.0.0 --port 8904
|
||||
Restart=always
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=尿检识别系统 Frontend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
WorkingDirectory=/home/ubuntu/nanocode/urinecheck/frontend
|
||||
ExecStart=/usr/bin/npm run dev
|
||||
Restart=always
|
||||
Environment=NODE_ENV=development
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,335 @@
|
||||
# C++ 尿检项目 JSON 数据格式识别逻辑分析
|
||||
|
||||
> 基于 `/home/ubuntu/nanocode/urinecheck/UrineCheck/` 源码分析
|
||||
> 分析日期:2026-05-13
|
||||
|
||||
---
|
||||
|
||||
## 1. 概述
|
||||
|
||||
C++ 尿检项目支持两种输入模式:
|
||||
- **图片模式**:传入试纸图片(JPG/PNG),从图像中定位色块并识别
|
||||
- **JSON数据模式**:直接传入已提取的色块颜色数据(H,S,V,L,A,B),跳过图像处理直接进行ML推理
|
||||
|
||||
本文档专注于 **JSON数据模式** 的完整处理流程。
|
||||
|
||||
---
|
||||
|
||||
## 2. 入口函数
|
||||
|
||||
### 2.1 `ProcessJsonDataRequest` (新版接口,匹配txt文件格式)
|
||||
|
||||
**位置**:`urinecheck_class.cpp:2052`
|
||||
|
||||
**输入JSON格式**:
|
||||
```json
|
||||
{
|
||||
"extend": {
|
||||
"color_14i": 2, // 试纸颜色类型(1=绿, 2=红, 3=蓝4联, 5=蓝14联...)
|
||||
"method": 1, // 1=干化学, 2=胶体金
|
||||
"type": 2 // 可选,优先级高于color_14i
|
||||
},
|
||||
"data": [
|
||||
[ // 第1帧(多帧采集,通常2-5帧)
|
||||
{"id": 0, "value": "7.62,146.43,37.76,23.22,135.71,134.46"},
|
||||
{"id": 1, "value": "24.73,38.34,155.7,159.45,126.1,138.93"},
|
||||
...
|
||||
{"id": 13, "value": "36.38,100.93,155.55,157.81,113.17,158.8"}
|
||||
],
|
||||
[...] // 第2帧
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**value字段含义**:6个逗号分隔的浮点数,对应:
|
||||
`H, S, V, L, A, B`
|
||||
|
||||
| 位置 | 符号 | 含义 | 取值范围 |
|
||||
|------|------|------|----------|
|
||||
| 0 | H | HSV 色相 | 0-360(但OpenCV存储为0-180) |
|
||||
| 1 | S | HSV 饱和度 | 0-255 |
|
||||
| 2 | V | HSV 明度 | 0-255 |
|
||||
| 3 | L | LAB 亮度 | 0-255 |
|
||||
| 4 | A | LAB a通道 | 0-255 |
|
||||
| 5 | B | LAB b通道 | 0-255 |
|
||||
|
||||
> **注意**:txt文件中的value数据已经是 **原始手机端提取的像素颜色值**,与C++图片模式中 `GetHsvLabVs()` 提取的格式一致。这些值在C++中未经白平衡处理就直接送入ML模型。
|
||||
|
||||
### 2.2 `analJsonData` (旧版接口)
|
||||
|
||||
**位置**:`urinecheck_class.cpp:746`
|
||||
|
||||
旧版使用不同的data结构——`data` 是一个对象而非数组:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"data0": [{"id": 0, "value": "..."}, ...],
|
||||
"data1": [{"id": 0, "value": "..."}, ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
旧版的 `id` 直接对应试纸上的物理位置(position),而新版 `id` 就是色块索引。
|
||||
|
||||
---
|
||||
|
||||
## 3. 完整处理流程
|
||||
|
||||
```
|
||||
ProcessJsonDataRequest(jsonBody)
|
||||
│
|
||||
├── 1. 解析JSON
|
||||
│ └── 提取 data 数组、extend 对象
|
||||
│
|
||||
├── 2. 确定试纸类型
|
||||
│ ├── extend.type 优先
|
||||
│ ├── extend.color_14i 其次
|
||||
│ └── paperType = paperType值 (1-20)
|
||||
│ X = paperType - 1 (用于查表索引)
|
||||
│
|
||||
├── 3. 转换项目类型
|
||||
│ └── projectId = getProjectType(paperType)
|
||||
│ ├── 1 (绿) → projectType=10
|
||||
│ ├── 2,5 (红) → projectType=2
|
||||
│ ├── 3,4 (蓝) → projectType=1
|
||||
│ └── ...
|
||||
│
|
||||
├── 4. 逐帧处理 ────────────────────────────┐
|
||||
│ for each frame in data: │
|
||||
│ │ │
|
||||
│ │ 4a. 解析每帧颜色数据 │
|
||||
│ │ mapData = { │
|
||||
│ │ id: [H,S,V,L,A,B] // 6个float │
|
||||
│ │ } │
|
||||
│ │ │
|
||||
│ │ 4b. 调用 ML 推理 │
|
||||
│ │ frameResult = │
|
||||
│ │ urineCheck14.Get14Results( │
|
||||
│ │ mapData, X) │
|
||||
│ │ │
|
||||
│ │ 4c. 收集结果 │
|
||||
│ │ results[position].push_back(idx) │
|
||||
│ └────────────────────────────────────────┘
|
||||
│
|
||||
├── 5. 多帧投票
|
||||
│ └── lastResult = CountResultsModal(results)
|
||||
│ 对每个position,取多帧结果中出现次数最多的index
|
||||
│
|
||||
├── 6. 关联修正
|
||||
│ ├── PRO/MAU 修正 (见 §6.2)
|
||||
│ └── SG/PH 修正 (见 §6.3)
|
||||
│
|
||||
├── 7. 索引映射输出
|
||||
│ ├── itemsStatIds[X][position] → 输出id
|
||||
│ └── itemsResultIdxMapTbl[X][position][rawIndex] → 输出index
|
||||
│
|
||||
└── 8. 构建输出JSON
|
||||
└── {"score":"100","status":"0","data":[{id, index}, ...]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心ML推理:`UrineCheck14::Get14Results(mapData, papertypeIndex)`
|
||||
|
||||
**位置**:`UrineCheck14.cpp:1041`
|
||||
|
||||
### 4.1 算法流程
|
||||
|
||||
```
|
||||
for each model in rtsArray[papertypeIndex]:
|
||||
① 从 mapData 取出该 position 的6个颜色值
|
||||
② 数据不足6个 → 跳过
|
||||
③ GetModelInstance(papertypeIndex, position) → 获取 RTrees 模型
|
||||
④ 模型为空 → 跳过
|
||||
⑤ 构造 float data[6] = {H, S, V, L, A, B}
|
||||
⑥ p = GetPredictResultVal(data)
|
||||
└── RTrees::predict(1×6 Mat) → 浮点数预测值
|
||||
⑦ 获取该 position 对应的结果索引函数
|
||||
⑧ pi = resultIndexFunc(data, p)
|
||||
└── 大部分直接 round(p),部分有HSV修正
|
||||
⑨ results[position] = pi
|
||||
```
|
||||
|
||||
### 4.2 ML模型配置
|
||||
|
||||
模型配置从 `/usr/local/Models/ml.json` 加载。
|
||||
|
||||
**ml.json 结构**:
|
||||
```json
|
||||
{
|
||||
"ml": [
|
||||
{
|
||||
"name": "glu", // 检测项目名
|
||||
"model": [
|
||||
{
|
||||
"positon": 0, // 色块位置(0-based)
|
||||
"id": 35, // 输出id
|
||||
"filepath": "/usr/local/Models/glu_0.data",
|
||||
"algorithm": 0, // 0=Red, 1=Green/Blue
|
||||
"indexmap": "0,1,2,3,4" // rawIndex → outputIndex映射
|
||||
},
|
||||
{
|
||||
"positon": 3,
|
||||
"filepath": "/usr/local/Models/glu-rts-M1.data",
|
||||
...
|
||||
}
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键设计**:
|
||||
- 同一个检测项目(如glu)在不同试纸类型(绿/红/蓝)上可能位于不同 position
|
||||
- 同一position在不同试纸类型上使用不同的 RTrees 模型文件
|
||||
- 总共支持 21 种试纸类型(MAX_PAPER_TYPE=21),每种最多15个色块(MAX_PAPER_BLOCK=15)
|
||||
|
||||
### 4.3 `GetPredictResultVal`
|
||||
|
||||
```cpp
|
||||
float UrineCheck14::GetPredictResultVal(float* sample) {
|
||||
cv::Mat sampleMat(1, 6, CV_32FC1, sample);
|
||||
return rtsPtr->predict(sampleMat); // OpenCV RTrees 预测
|
||||
}
|
||||
```
|
||||
|
||||
输入:6维特征向量 (H,S,V,L,A,B)
|
||||
输出:浮点数预测值(代表颜色等级,如 0=阴性, 1=弱阳, 2=中阳...)
|
||||
|
||||
---
|
||||
|
||||
## 5. 各检测项目的索引修正函数
|
||||
|
||||
所有修正函数都先调用 `round(p)` 取四舍五入整数,然后根据 HSV 值进行二次修正。
|
||||
|
||||
### 5.1 红色试纸(algorithm=0)修正逻辑
|
||||
|
||||
| 项目 | 修正函数 | 修正规则 |
|
||||
|------|----------|----------|
|
||||
| **PRO (蛋白质)** | `GetRedProResultIndex` | 若 ret>0 且 (H<25 或 H>110) → 强制返回0 |
|
||||
| **MA (微量白蛋白)** | `GetRedMauResultIndex` | 若 ret>0 且 (H<30 或 H>115) → 强制返回0 |
|
||||
| **KET (酮体)** | `GetRedKetResultIndex` | 若 ret>1 且 (45<H<145) → 强制返回0 |
|
||||
| **NIT (亚硝酸盐)** | `GetRedNitResultIndex` | 若 ret>0 且 (40<H<140) → 强制返回0 |
|
||||
| **URO (尿胆原)** | `GetRedUroResultIndex` | 若 ret>0 且 (25<H<150) → 强制返回0 |
|
||||
| **BLD (潜血)** | `GetRedBldResultIndex` | 直接 `round(p)`,无修正 |
|
||||
| **VC (抗坏血酸)** | `GetRedVcResultIndex` | 直接 `round(p)`,无修正 |
|
||||
| **BIL (胆红素)** | `GetRedBilResultIndex` | 直接 `round(p)`,无修正 |
|
||||
| **GLU (葡萄糖)** | `GetRedGluResultIndex` | 直接 `round(p)`,无修正 |
|
||||
| **CRE (肌酐)** | `GetRedCreResultIndex` | 直接 `round(p)`,无修正 |
|
||||
| **Ca (尿钙)** | `GetRedUcaResultIndex` | 直接 `round(p)`,无修正 |
|
||||
| **SG (比重)** | `GetRedSgResultIndex` | 直接 `round(p)`,无修正 |
|
||||
| **pH** | `GetRedPhResultIndex` | 直接 `round(p)`,无修正 |
|
||||
| **WBC (白细胞)** | `GetRedWbcResultIndex` | 直接 `round(p)`,无修正 |
|
||||
|
||||
### 5.2 绿色/蓝色试纸(algorithm>0)修正逻辑
|
||||
|
||||
大部分直接 `round(p)`,无额外修正。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关联修正规则
|
||||
|
||||
### 6.1 配置来源
|
||||
|
||||
`positionsPro[X]`、`positionsMau[X]`、`positionsSg[X]`、`positionsPh[X]` 从 `ml.json` 中提取,记录了各试纸类型上 PRO/MAU/SG/pH 对应的色块位置。
|
||||
|
||||
### 6.2 PRO(蛋白质)与 MAU(微量白蛋白)关联
|
||||
|
||||
```
|
||||
maxMau = (X > 0) ? 3 : 2 // 红色3级,其他2级
|
||||
|
||||
情况1: mauval == 0
|
||||
→ proVal = 0 // MA阴性时PRO必定阴性
|
||||
|
||||
情况2: proVal == 0 且 mauval == maxMau
|
||||
→ mauval = maxMau - 1 // PRO阴性时MA不能满级
|
||||
|
||||
情况3: proVal >= 1
|
||||
→ mauval = maxMau // PRO阳性时MA设为满级
|
||||
```
|
||||
|
||||
### 6.3 SG(比重)与 pH 关联
|
||||
|
||||
```
|
||||
maxSg = (X > 0) ? 6 : 5 // 红色6级,其他5级
|
||||
|
||||
条件: projectType < 20 且 phVal >= 2 且 sgVal < maxSg
|
||||
→ sgVal += 1 // 在特定pH下上调比重一级
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 索引映射(indexmap)
|
||||
|
||||
### 7.1 映射表结构
|
||||
|
||||
`itemsResultIdxMapTbl[MAX_PAPER_TYPE][MAX_PAPER_BLOCK][MAX_ALG_TAG]`
|
||||
|
||||
三维查表:`[试纸类型][色块位置][算法原始输出index]` → 最终输出index
|
||||
|
||||
### 7.2 indexmap示例
|
||||
|
||||
```json
|
||||
"indexmap": "0,1,2,3,4"
|
||||
```
|
||||
含义:算法输出的 index=0 映射为输出0,index=1 映射为输出1...
|
||||
|
||||
```json
|
||||
"indexmap": "5,6,2,3,4"
|
||||
```
|
||||
含义:算法输出的 index=0→5, index=1→6, index=2→2...
|
||||
|
||||
**用途**:不同试纸类型可能使用不同的等级映射,通过 indexmap 灵活配置。
|
||||
|
||||
---
|
||||
|
||||
## 8. 输出JSON格式
|
||||
|
||||
```json
|
||||
{
|
||||
"score": "100",
|
||||
"status": "0",
|
||||
"message": "OK",
|
||||
"imgid": 0,
|
||||
"project_id": 2,
|
||||
"paper_id": 2,
|
||||
"versioninfo": "...",
|
||||
"data": [
|
||||
{"id": 35, "index": 1}, // 葡萄糖: 弱阳性
|
||||
{"id": 36, "index": 0}, // 白细胞: 阴性
|
||||
{"id": 37, "index": 0}, // 亚硝酸盐: 阴性
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**data数组**:每个元素包含 `id`(项目ID,如35=GLU)和 `index`(等级,0=阴性)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 新旧JSON格式差异总结
|
||||
|
||||
| 特性 | 旧版 (analJsonData) | 新版 (ProcessJsonDataRequest) |
|
||||
|------|---------------------|-------------------------------|
|
||||
| 入口函数 | `analJsonData` | `ProcessJsonDataRequest` |
|
||||
| data结构 | `{"data0": [...], "data1": [...]}` | `[[...], [...]]` (直接数组) |
|
||||
| 试纸类型 | 从 `project_id` 或 `extend` 推断 | 从 `extend.type` 或 `extend.color_14i` |
|
||||
| seekey | `id` 是物理位置 | `id` 是色块索引(0-13) |
|
||||
| 数据格式 | 整数<br>(如 `"7,146,37,23,135,134"`) | 浮点数<br>(如 `"7.62,146.43,37.76..."`) |
|
||||
| 对应txt | 不匹配当前txt | **匹配** txt文件格式 |
|
||||
| pro/mau修正 | 在analJsonData内联 | 在ProcessJsonDataRequest内联 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 关键文件清单
|
||||
|
||||
| 文件 | 功能 |
|
||||
|------|------|
|
||||
| `urinecheck_class.cpp:2052-2217` | `ProcessJsonDataRequest` - 新版JSON处理入口 |
|
||||
| `urinecheck_class.cpp:746-1078` | `analJsonData` - 旧版JSON处理 |
|
||||
| `urinecheck_class.cpp:54-172` | `initconfig()` - 加载 ml.json 配置 |
|
||||
| `UrineCheck14.cpp:1041-1104` | `Get14Results(mapData)` - JSON数据的ML推理 |
|
||||
| `UrineCheck14.cpp:1107-1176` | `Get14Results(mat, pts)` - 图片数据的ML推理 |
|
||||
| `UrineCheck14.cpp:32-230` | `initconfig()` - 加载模型文件和注册结果函数 |
|
||||
| `UrineCheck14.cpp:1195-1199` | `GetPredictResultVal` - RTrees预测 |
|
||||
| `UrineCheck14.cpp:1413-1746` | Red系列结果索引修正函数 |
|
||||
@@ -0,0 +1,128 @@
|
||||
# Python后端 JSON数据尿检识别 - 技术实施方案
|
||||
|
||||
> 基于 C++ `ProcessJsonDataRequest` 逐行翻译到 Python
|
||||
> 依赖:`/usr/local/Models/ml.json` + `*.data` (OpenCV RTrees 模型)
|
||||
|
||||
---
|
||||
|
||||
## 1. 可行性验证
|
||||
|
||||
| 检查项 | 状态 |
|
||||
|--------|------|
|
||||
| OpenCV 版本 | 4.13.0 ✅ |
|
||||
| cv2.ml.RTrees_load() 加载 .data 文件 | ✅ uro_0, glu_1, glu-rts-M1 均加载成功 |
|
||||
| RTrees.predict() 预测 | ✅ `predict(sample)` 返回浮点数 |
|
||||
| /usr/local/Models/ 所有模型文件存在 | ✅ 14项×2+ (Green+Rts) 全部存在 |
|
||||
| ml.json 配置完整 | ✅ 包含所有14项×21种试纸类型配置 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
```
|
||||
POST /api/urinecheck/analyze-json
|
||||
│
|
||||
├── 接收 JSON body (与txt文件格式一致)
|
||||
│
|
||||
├── MLConfigLoader ─── 解析 /usr/local/Models/ml.json
|
||||
│ ├── 构建 model_lookup[paperType][position] = (model, indexFunc, indexMap)
|
||||
│ └── 标注 PRO/MAU/SG/pH 的 position
|
||||
│
|
||||
├── UrineJsonRecognizer
|
||||
│ ├── 1. 解析输入JSON
|
||||
│ ├── 2. extract paperType from extend
|
||||
│ ├── 3. 逐帧处理
|
||||
│ │ └── 每帧: mapData[id→[6 floats]] → ML推理 → {position: rawIndex}
|
||||
│ ├── 4. CountResultsModal (多帧投票)
|
||||
│ ├── 5. PRO/MAU + SG/PH 关联修正
|
||||
│ ├── 6. indexMap 映射
|
||||
│ └── 7. 构建输出JSON
|
||||
│
|
||||
└── 返回 {"score":"100", "status":"0", "data": [{id, index}, ...]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 实施步骤
|
||||
|
||||
### Step 1: 新建 `ml_config.py` — ML配置加载器
|
||||
|
||||
**功能**:解析 `/usr/local/Models/ml.json`,构建运行时查表结构
|
||||
|
||||
```python
|
||||
class MLConfigLoader:
|
||||
def load(self):
|
||||
# 解析 ml.json
|
||||
# 对每个 item (uro/glu/ket/...):
|
||||
# 对每个 model entry (positon, id, filepath, algorithm, indexmap):
|
||||
# paperType = j (model数组中的索引)
|
||||
# if positon >= 0:
|
||||
# models[paperType][positon] = RTrees.load(filepath)
|
||||
# result_funcs[paperType][positon] = indexFuncName
|
||||
# stat_ids[paperType][positon] = id
|
||||
# index_maps[paperType][positon] = [int(x) for x in indexmap.split(',')]
|
||||
# if name == 'pro': positionsPro[paperType] = positon
|
||||
# ... etc
|
||||
```
|
||||
|
||||
### Step 2: 新建 `json_recognizer.py` — JSON数据识别器
|
||||
|
||||
**核心类**:`UrineJsonRecognizer`
|
||||
|
||||
```python
|
||||
class UrineJsonRecognizer:
|
||||
def recognize(self, json_data: dict) -> dict:
|
||||
# 完整复制 ProcessJsonDataRequest 逻辑
|
||||
...
|
||||
|
||||
def _get14_results(self, map_data: dict, paper_type: int) -> dict:
|
||||
# 完整复制 UrineCheck14::Get14Results(mapData, X) 逻辑
|
||||
...
|
||||
```
|
||||
|
||||
**各检测项的结果索引修正函数**(直接翻译C++):
|
||||
- `_red_pro_index(p, h)` — if ret>0 and (h<25 or h>110) → 0
|
||||
- `_red_mau_index(p, h)` — if ret>0 and (h<30 or h>115) → 0
|
||||
- `_red_ket_index(p, h)` — if ret>1 and 45<h<145 → 0
|
||||
- `_red_nit_index(p, h)` — if ret>0 and 40<h<140 → 0
|
||||
- `_red_uro_index(p, h)` — if ret>0 and 25<h<150 → 0
|
||||
- 其余直接 `round(p)`
|
||||
|
||||
### Step 3: 添加 API 路由
|
||||
|
||||
在 `routers/urinecheck.py` 中添加:
|
||||
```python
|
||||
@router.post("/analyze-json")
|
||||
async def analyze_json(request: JsonDataRequest, ...):
|
||||
recognizer = get_recognizer() # 全局单例
|
||||
return recognizer.recognize(request.model_dump())
|
||||
```
|
||||
|
||||
### Step 4: 在 `schemas.py` 中添加请求模型
|
||||
|
||||
### Step 5: 在 `app.py` 中初始化识别器(startup event)
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键差异注意事项
|
||||
|
||||
| C++ | Python |
|
||||
|-----|--------|
|
||||
| Json::Value | dict/list |
|
||||
| map<int, Ptr<RTrees>> | dict[int, cv2.ml.RTrees] |
|
||||
| `atoi()` string→int | `int()` 或 `float()` |
|
||||
| `split(s, ',', vs)` | `s.split(',')` |
|
||||
| `GetModal1()` → 众数 | `collections.Counter.most_common(1)` |
|
||||
| `GetPredictResultVal(data)` | `model.predict(np.array([data], dtype=np.float32))` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 文件清单
|
||||
|
||||
| 文件 | 操作 | 功能 |
|
||||
|------|------|------|
|
||||
| `backend/ml_config.py` | **新建** | ML配置加载器 |
|
||||
| `backend/json_recognizer.py` | **新建** | JSON数据识别核心逻辑 |
|
||||
| `backend/schemas.py` | **修改** | 添加 JsonDataRequest |
|
||||
| `backend/routers/urinecheck.py` | **修改** | 添加 /analyze-json 路由 |
|
||||
| `backend/app.py` | **修改** | 启动时初始化识别器 |
|
||||
@@ -0,0 +1,459 @@
|
||||
# 尿检试纸颜色识别功能 - 改造方案
|
||||
|
||||
> 创建时间:2026-05-03
|
||||
> 基于:urinecheckcpp C++ 代码 (鸿蒙版) → OpenCV Python 重写
|
||||
> 状态:方案讨论中,待确认后动工
|
||||
|
||||
---
|
||||
|
||||
## 一、现有 C++ 代码理解
|
||||
|
||||
### 1.1 试纸结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 试纸物理结构 │
|
||||
│ │
|
||||
│ ┌────────┐ ┌──┐ │
|
||||
│ │ 大定位块 │ [0][1][2][3][4][5][6][7][8][9][10][11][12][13] │ 小定位块 │
|
||||
│ │ (10/15mm)│ ←── 14个反应色块 ──→ │ (3mm) │
|
||||
│ └────────┘ └──┘ │
|
||||
│ │
|
||||
│ 定位块颜色: 红/绿/蓝 (两个定位块颜色必须相同) │
|
||||
│ 大定位块: 10mm×5mm (S型, 宽高比2.0) 或 15mm×5mm (L型, 宽高比3.0) │
|
||||
│ 小定位块: 3mm×5mm (宽高比≈1.667) │
|
||||
│ 色块间距: 3.5个单位 (每单位2mm = 7mm间距) │
|
||||
│ 第一个色块位置: 2.25个单位 (距离小定位块边缘4.5mm) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.2 核心算法流程(7步)
|
||||
|
||||
| 步骤 | 名称 | 关键操作 |
|
||||
|------|------|---------|
|
||||
| 1 | 图像预处理 | BGR/HSV/Gray转换, 高斯滤波(5×5) |
|
||||
| 2 | 定位块检测 | HSV颜色分割(红/绿/蓝) → 形态学(CLOSE+OPEN, 9×9核) → 轮廓查找; 备选Canny边缘 |
|
||||
| 3 | 候选筛选+配对 | 面积/宽高比/矩形度过滤 → Y坐标聚类 → 遍历配对验证(颜色一致+RGB距离<180+面积比±20%+Y差<4倍高度) |
|
||||
| 4 | 清晰度评估 | Laplacian方差法, 分级: 清晰>300 / 轻微>100 / 严重<100 |
|
||||
| 5 | 杂物检测 | 试纸高度≥30px, 候选数量≤15 |
|
||||
| 6 | 规格匹配 | distRatio(定位块距离/试纸高度) + areaRatio → 6种规格之一 |
|
||||
| 7 | 色块位置计算 | 边缘中心点 + 切片插值(slicesNum=50, firstPos=2.25, step=3.5) → 14个中心点 |
|
||||
|
||||
### 1.3 颜色读取
|
||||
|
||||
- **白平衡**: 灰度世界算法(各通道均值缩放到相同目标值)
|
||||
- **采样**: 圆形掩码(半径3px)取均值
|
||||
- **输出**: RGB + HSV(H:0-360) + LAB(L:0-100, a/b:-128-127)
|
||||
- **顺序**: 始终从靠近小定位块的色块开始编号(id=0)
|
||||
|
||||
---
|
||||
|
||||
## 二、改造方案
|
||||
|
||||
### 2.1 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 新增页面: /admin/urinecheck │ │
|
||||
│ │ ├── 图片上传 (拖拽/点击, 支持 jpg/png) │ │
|
||||
│ │ ├── 上传预览 (显示原图) │ │
|
||||
│ │ ├── 识别按钮 (调用后端API) │ │
|
||||
│ │ ├── 标注图展示 (色块中心红圈标记) │ │
|
||||
│ │ └── 结果输出框 (14项颜色值表格) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ HTTP POST /api/urinecheck │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Backend (FastAPI) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 新增路由: routers/urinecheck.py │ │
|
||||
│ │ ├── POST /api/urinecheck/analyze │ │
|
||||
│ │ │ 接收: multipart/form-data (image file) │ │
|
||||
│ │ │ 返回: JSON { colors, annotated_image_url, ... } │ │
|
||||
│ │ └── GET /api/urinecheck/result/{id} │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ↓ 调用 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 算法模块: urine_recognizer.py (新建) │ │
|
||||
│ │ ├── class UrineStripRecognizer │ │
|
||||
│ │ ├── class ColorReader │ │
|
||||
│ │ ├── class PaperConfig │ │
|
||||
│ │ └── 全局参数常量 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 文件清单
|
||||
|
||||
```
|
||||
personal-blog/
|
||||
├── backend/
|
||||
│ ├── routers/
|
||||
│ │ └── urinecheck.py # 新增: API路由
|
||||
│ ├── urine_recognizer.py # 新增: 核心识别算法 (Python重写)
|
||||
│ ├── app.py # 修改: 注册新路由
|
||||
│ └── requirements.txt # 已满足: opencv-python已安装
|
||||
├── frontend/src/
|
||||
│ ├── pages/admin/
|
||||
│ │ └── UrineCheck.jsx # 新增: 尿检识别页面
|
||||
│ ├── components-preline/
|
||||
│ │ └── Sidebar.jsx # 修改: 添加导航项
|
||||
│ ├── App.jsx # 修改: 添加路由
|
||||
│ └── api/
|
||||
│ └── urinecheck.js # 新增: API调用封装
|
||||
└── docs/
|
||||
└── urinecheck-plan.md # 本文件
|
||||
```
|
||||
|
||||
### 2.3 Python 重写的关键数据结构
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
class LocatorColor(Enum):
|
||||
UNKNOWN = 0
|
||||
RED = 1
|
||||
GREEN = 2
|
||||
BLUE = 3
|
||||
|
||||
class BigLocatorType(Enum):
|
||||
SHORT = 0 # 10mm
|
||||
LONG = 1 # 15mm
|
||||
|
||||
class PaperSpatialType(Enum):
|
||||
UNKNOWN = 0
|
||||
PST_14_S = 1
|
||||
PST_14_L = 2
|
||||
PST_11_S = 3
|
||||
PST_11_L = 4
|
||||
PST_8_S = 5
|
||||
PST_8_L = 6
|
||||
|
||||
@dataclass
|
||||
class LocatorInfo:
|
||||
rotated_rect: cv2.RotatedRect = None
|
||||
center: tuple = (0, 0)
|
||||
area: float = 0
|
||||
width: float = 0
|
||||
height: float = 0
|
||||
color: LocatorColor = LocatorColor.UNKNOWN
|
||||
is_big: bool = False
|
||||
avg_color: tuple = (0, 0, 0)
|
||||
|
||||
@dataclass
|
||||
class ColorResult:
|
||||
id: int = 0
|
||||
r: float = 0; g: float = 0; b: float = 0
|
||||
h: float = 0; s: float = 0; v: float = 0
|
||||
l: float = 0; a: float = 0; b_val: float = 0 # LAB
|
||||
|
||||
@dataclass
|
||||
class RecognitionResult:
|
||||
success: bool = False
|
||||
error_code: int = -1
|
||||
error_message: str = ""
|
||||
paper_type: PaperSpatialType = PaperSpatialType.UNKNOWN
|
||||
locator_color: LocatorColor = LocatorColor.UNKNOWN
|
||||
item_count: int = 0
|
||||
color_blocks: list = field(default_factory=list) # list of ColorResult
|
||||
annotated_image: np.ndarray = None # 标注图 (BGR)
|
||||
blur_level: int = 0 # 0=清晰, 1=轻微, 2=严重
|
||||
blur_score: float = 0
|
||||
```
|
||||
|
||||
### 2.4 全局参数 (直接平移 C++ GlobalParams)
|
||||
|
||||
```python
|
||||
# 颜色误差
|
||||
COLOR_DISTANCE_THRESHOLD = 180.0
|
||||
|
||||
# 面积/距离/角度误差
|
||||
AREA_ERROR_RATIO = 0.20
|
||||
DISTANCE_ERROR_RATIO = 0.20
|
||||
ANGLE_ERROR_DEGREE = 15.0
|
||||
|
||||
# 定位块宽高比
|
||||
BIG_LOCATOR_RATIO_SHORT = 2.0 # 10mm: 10/5
|
||||
BIG_LOCATOR_RATIO_LONG = 3.0 # 15mm: 15/5
|
||||
SMALL_LOCATOR_RATIO = 1.667 # 5/3
|
||||
|
||||
# 面积比
|
||||
AREA_RATIO_SHORT = 3.33 # 10/3
|
||||
AREA_RATIO_LONG = 5.0 # 15/3
|
||||
|
||||
# 采样
|
||||
MIN_SAMPLE_PIXELS = 25
|
||||
DEFAULT_SAMPLE_RADIUS = 3
|
||||
|
||||
# 轮廓过滤
|
||||
MIN_CONTOUR_AREA = 100.0
|
||||
MAX_AREA_RATIO = 0.3
|
||||
MIN_RECTANGULARITY = 0.70
|
||||
|
||||
# 定位块面积限制
|
||||
MIN_SMALL_LOCATOR_AREA = 100.0
|
||||
MAX_SMALL_LOCATOR_AREA = 8000.0
|
||||
MIN_BIG_LOCATOR_AREA = 200.0
|
||||
MAX_BIG_LOCATOR_AREA = 15000.0
|
||||
```
|
||||
|
||||
### 2.5 试纸配置 (14联S型/L型)
|
||||
|
||||
```python
|
||||
PAPER_CONFIGS = {
|
||||
PaperSpatialType.PST_14_S: {
|
||||
"items_num": 14, "slices_num": 50,
|
||||
"first_pos": 2.25, "step": 3.5, "dist_times": 21.3,
|
||||
"wh_big_ratio": 2.0
|
||||
},
|
||||
PaperSpatialType.PST_14_L: {
|
||||
"items_num": 14, "slices_num": 50,
|
||||
"first_pos": 2.25, "step": 3.5, "dist_times": 21.8,
|
||||
"wh_big_ratio": 3.0
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 API 设计
|
||||
|
||||
**POST /api/urinecheck/analyze**
|
||||
|
||||
请求:
|
||||
```
|
||||
multipart/form-data
|
||||
- file: 试纸图片 (jpg/png)
|
||||
```
|
||||
|
||||
成功响应 (200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"paper_type": "PST_14_S",
|
||||
"locator_color": "RED",
|
||||
"item_count": 14,
|
||||
"blur_level": 0,
|
||||
"blur_score": 450.5,
|
||||
"colors": [
|
||||
{
|
||||
"id": 0,
|
||||
"rgb": {"r": 200, "g": 180, "b": 150},
|
||||
"hsv": {"h": 36.0, "s": 64.0, "v": 200.0},
|
||||
"lab": {"l": 75.0, "a": 5.0, "b": 18.0}
|
||||
},
|
||||
// ... 共14项
|
||||
],
|
||||
"annotated_image_url": "/uploads/urinecheck/annotated_xxx.jpg"
|
||||
}
|
||||
```
|
||||
|
||||
失败响应:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error_code": 2,
|
||||
"error_message": "No valid locator pair found"
|
||||
}
|
||||
```
|
||||
|
||||
**GET /api/urinecheck/result/{filename}** — 获取标注图
|
||||
|
||||
### 2.7 错误码体系 (与C++一致)
|
||||
|
||||
| 错误码 | 含义 |
|
||||
|--------|------|
|
||||
| 0 | 成功 |
|
||||
| 1 | 未找到轮廓/候选不足 |
|
||||
| 2 | 未找到有效定位块配对 |
|
||||
| 3 | 定位块颜色不一致/未知 |
|
||||
| 4 | 试纸规格未匹配 |
|
||||
| 5 | 色块位置计算失败 |
|
||||
| 6 | 检测到多条试纸 |
|
||||
| 7 | 输入图像为空 |
|
||||
| 9 | 试纸过小(杂物) |
|
||||
| 10 | 场景过于杂乱 |
|
||||
|
||||
---
|
||||
|
||||
## 三、实施步骤
|
||||
|
||||
### 第1步: 编写 Python 核心算法模块
|
||||
|
||||
**文件**: `backend/urine_recognizer.py`
|
||||
|
||||
完整平移 C++ `PaperRecognizer` + `ColorReader` + `PaperConfig` 逻辑:
|
||||
|
||||
1. `class GlobalParams` — 所有常量
|
||||
2. `class PaperConfig` — 试纸配置字典
|
||||
3. `class ColorReader` — 白平衡 + 圆形采样 + HSV/LAB转换
|
||||
4. `class UrineStripRecognizer` — 核心7步流程:
|
||||
- `preprocess_image()`
|
||||
- `detect_locators()` — 包括 HSV分割、轮廓查找、备选Canny
|
||||
- `filter_locator_candidates()` — 多级过滤 + Y聚类 + 配对验证
|
||||
- `match_paper_spec()` — distRatio/areaRatio 匹配
|
||||
- `calculate_color_block_centers()` — 边缘中心 + 切片插值
|
||||
- `draw_annotations()` — 色块红圈标记
|
||||
- `recognize()` — 主入口
|
||||
|
||||
**注意事项**:
|
||||
- Python OpenCV 中 `cv2.findContours` 返回2个值(OpenCV 4.x),与C++ 不同
|
||||
- 颜色空间: C++ BGR ↔ Python BGR 一致,无需转换
|
||||
- 旋转矩形的 `.points()` / `.boundingRect()` 函数签名略有不同
|
||||
- 移除所有鸿蒙日志宏(PR_LOG_*),改用 Python `logging` 模块
|
||||
- 移除 `getPaperImg()` / `savePaperImg()` — 博客项目不需要RGBA格式
|
||||
|
||||
### 第2步: 编写后端 API 路由
|
||||
|
||||
**文件**: `backend/routers/urinecheck.py`
|
||||
|
||||
- `POST /api/urinecheck/analyze`
|
||||
- 接收上传的图片文件
|
||||
- 保存到 `/tmp/urinecheck_<uuid>.jpg` (方便清理)
|
||||
- 调用 `UrineStripRecognizer.recognize()`
|
||||
- 如果成功,保存标注图到 `backend/uploads/urinecheck/`
|
||||
- 返回 JSON 结果
|
||||
- `GET /api/urinecheck/result/{filename}` — 静态文件服务(标注图)
|
||||
|
||||
### 第3步: 注册路由
|
||||
|
||||
**修改** `backend/app.py`:
|
||||
```python
|
||||
from routers import urinecheck
|
||||
app.include_router(urinecheck.router)
|
||||
```
|
||||
|
||||
### 第4步: 编写前端页面
|
||||
|
||||
**文件**: `frontend/src/pages/admin/UrineCheck.jsx`
|
||||
|
||||
UI布局 (Preline风格,参考Images.jsx):
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 尿检识别 │
|
||||
│ 上传尿常规试纸图片,自动识别14项颜色值 │
|
||||
├──────────────────┬───────────────────────────┤
|
||||
│ 上传区域 │ 结果输出区 │
|
||||
│ ┌─────────────┐ │ ┌─────────────────────┐ │
|
||||
│ │ 📤 拖拽或点击 │ │ │ 定位块颜色: RED │ │
|
||||
│ │ 上传试纸图片 │ │ │ 试纸规格: 14联S型 │ │
|
||||
│ │ jpg/png │ │ │ 清晰度: 清晰 (450) │ │
|
||||
│ └─────────────┘ │ ├─────────────────────┤ │
|
||||
│ │ │ # | RGB | HSV │ │
|
||||
│ ┌─────────────┐ │ │ 0 | 200,180,150 | ... │ │
|
||||
│ │ [预览图] │ │ │ 1 | ... │ │
|
||||
│ │ │ │ │ ... │ │
|
||||
│ │ │ │ │ 13| ... │ │
|
||||
│ └─────────────┘ │ └─────────────────────┘ │
|
||||
├──────────────────┴───────────────────────────┤
|
||||
│ 标注图展示区 (色块中心红圈标记) │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ [标注后的试纸大图] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
功能交互:
|
||||
1. 用户上传图片 (拖拽或点击)
|
||||
2. 图片立即显示预览(本地预览,无需等待)
|
||||
3. 用户点击"开始识别"按钮
|
||||
4. 前端调用 `POST /api/urinecheck/analyze`
|
||||
5. 结果显示在右侧:
|
||||
- 基本信息(定位块颜色、试纸类型、清晰度)
|
||||
- 14项颜色值表格(RGB + HSV + LAB)
|
||||
- 标注图展示
|
||||
|
||||
### 第5步: 修改导航
|
||||
|
||||
**文件**: `frontend/src/components-preline/Sidebar.jsx` — 在 `navItems` 中添加:
|
||||
```javascript
|
||||
{ path: '/admin/urinecheck', label: '尿检识别', icon: TestTubeIcon },
|
||||
```
|
||||
|
||||
### 第6步: 修改路由
|
||||
|
||||
**文件**: `frontend/src/App.jsx` — 添加:
|
||||
```jsx
|
||||
import { UrineCheck } from './pages/admin/UrineCheck'
|
||||
// ...
|
||||
<Route path="/admin/urinecheck" element={<PrivateRoute><UrineCheck /></PrivateRoute>} />
|
||||
```
|
||||
|
||||
### 第7步: 测试验证
|
||||
|
||||
使用 `urinecheckcpp/images/` 下的测试图片验证:
|
||||
```bash
|
||||
curl -X POST http://localhost:8889/api/urinecheck/analyze \
|
||||
-F "file=@test1.png"
|
||||
```
|
||||
|
||||
验证:
|
||||
- 定位块正确识别
|
||||
- 色块数量 = 14
|
||||
- 颜色值合理 (RGB在0-255范围)
|
||||
- 标注图生成正确
|
||||
|
||||
---
|
||||
|
||||
## 四、已确认的决定
|
||||
|
||||
1. ✅ **全部6种规格都支持** — 8联(S/L)、11联(S/L)、14联(S/L)
|
||||
2. ✅ **输出中添加检测项目名称** — 14/11/8项都有对应的中文名称
|
||||
3. ✅ **不需要标注图** — 只需颜色数据,移除 `draw_annotations()`
|
||||
4. ✅ **不存数据库** — 每次临时识别,不上传文件到 uploads 目录
|
||||
5. ✅ **上传图片存 /tmp** — 使用 `tempfile` 自动清理
|
||||
6. ✅ **前端 Preline UI 风格** — 与现有管理后台一致
|
||||
|
||||
---
|
||||
|
||||
## 五、检测项目名称映射
|
||||
|
||||
### 14联 (id=0 靠近小定位块)
|
||||
|
||||
| id | 项目 | id | 项目 |
|
||||
|----|------|----|------|
|
||||
| 0 | 微量白蛋白(MA) | 7 | 比重(SG) |
|
||||
| 1 | 钙(Ca) | 8 | 潜血(BLD) |
|
||||
| 2 | 肌酐(CRE) | 9 | pH |
|
||||
| 3 | 抗坏血酸(VC) | 10 | 蛋白质(PRO) |
|
||||
| 4 | 葡萄糖(GLU) | 11 | 尿胆原(URO) |
|
||||
| 5 | 胆红素(BIL) | 12 | 亚硝酸盐(NIT) |
|
||||
| 6 | 酮体(KET) | 13 | 白细胞(LEU) |
|
||||
|
||||
### 11联
|
||||
|
||||
| id | 项目 | id | 项目 |
|
||||
|----|------|----|------|
|
||||
| 0 | 抗坏血酸(VC) | 6 | pH |
|
||||
| 1 | 葡萄糖(GLU) | 7 | 蛋白质(PRO) |
|
||||
| 2 | 胆红素(BIL) | 8 | 尿胆原(URO) |
|
||||
| 3 | 酮体(KET) | 9 | 亚硝酸盐(NIT) |
|
||||
| 4 | 比重(SG) | 10 | 白细胞(LEU) |
|
||||
| 5 | 潜血(BLD) | | |
|
||||
|
||||
### 8联
|
||||
|
||||
| id | 项目 | id | 项目 |
|
||||
|----|------|----|------|
|
||||
| 0 | 葡萄糖(GLU) | 4 | 潜血(BLD) |
|
||||
| 1 | 胆红素(BIL) | 5 | pH |
|
||||
| 2 | 酮体(KET) | 6 | 蛋白质(PRO) |
|
||||
| 3 | 比重(SG) | 7 | 白细胞(LEU) |
|
||||
|
||||
> ⚠️ 注意:项目名称根据常见尿试纸排列推定,不同品牌/批次可能顺序不同。
|
||||
> 建议对照实际试纸包装确认顺序。代码中的映射在 `urine_recognizer.py` 的 `ITEM_NAMES_*` 中定义。
|
||||
|
||||
---
|
||||
|
||||
## 六、技术风险与注意事项
|
||||
| OpenCV 版本差异 | Python OpenCV API与C++有细微差异 | 已在代码审查中标注关键差异点 |
|
||||
| 颜色分割阈值 | 不同光照下HSV阈值需要微调 | 直接复用C++验证过的阈值 |
|
||||
| 性能 | Python可能比C++慢 | 单张图片秒级处理,可接受 |
|
||||
| 图片格式 | 用户可能上传超大图片 | 限制最大10MB,压缩到合理尺寸 |
|
||||
| systemd 写入限制 | 如遇ReadWritePaths限制 | 确保 /tmp 和 backend/uploads/ 可写 |
|
||||
|
||||
---
|
||||
|
||||
**请蔡叔叔审阅以上方案,确认后我开始写代码。有问题随时指出。**
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>尿检识别系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3179
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "urinecheck",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"preline": "^4.1.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Login } from './pages/Login'
|
||||
import { UrineCheck } from './pages/UrineCheck'
|
||||
import { JsonDataCheck } from './pages/JsonDataCheck'
|
||||
import { authApi } from './api/auth'
|
||||
|
||||
// 路由守卫 —— 支持本地 token 和 SSO Cookie 双模式
|
||||
function PrivateRoute({ children }) {
|
||||
const [authed, setAuthed] = useState(null) // null = 检测中
|
||||
|
||||
useEffect(() => {
|
||||
// 优先检查本地 token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
setAuthed(true)
|
||||
return
|
||||
}
|
||||
// 无本地 token,尝试 SSO Cookie
|
||||
authApi.getMe()
|
||||
.then(() => setAuthed(true))
|
||||
.catch(() => setAuthed(false))
|
||||
}, [])
|
||||
|
||||
if (authed === null) return null // 加载中不渲染
|
||||
return authed ? children : <Navigate to="/login" />
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* 登录路由 */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* 管理后台路由(需要登录) */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Navigate to="/admin/urinecheck" />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/urinecheck"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<UrineCheck />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/json-check"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<JsonDataCheck />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 默认重定向 */}
|
||||
<Route path="/" element={<Navigate to="/admin/urinecheck" />} />
|
||||
<Route path="*" element={<Navigate to="/admin/urinecheck" />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,23 @@
|
||||
// 认证相关 API
|
||||
import api from './config'
|
||||
|
||||
export const authApi = {
|
||||
login: (username, password) => {
|
||||
return api.post('/auth/login', { username, password })
|
||||
},
|
||||
|
||||
getMe: () => {
|
||||
return api.get('/auth/me')
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
return api.post('/auth/logout')
|
||||
},
|
||||
|
||||
changePassword: (oldPassword, newPassword) => {
|
||||
return api.post('/auth/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// API基础配置 - 使用后端端口 8904
|
||||
export const API_BASE_URL = 'http://129.211.173.87:8904/api'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 10000,
|
||||
withCredentials: true, // 允许跨域携带 Cookie(SSO 必需)
|
||||
})
|
||||
|
||||
// 请求拦截器 - 添加Token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器 - 处理401
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
// 避免在登录页本身触发跳转导致死循环刷新
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,68 @@
|
||||
// Preline UI 风格按钮组件
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Preline 风格按钮
|
||||
* @param {string} variant - 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
* @param {string} size - 'sm' | 'md' | 'lg'
|
||||
* @param {boolean} disabled - 是否禁用
|
||||
* @param {boolean} loading - 是否加载中
|
||||
* @param {React.ReactNode} children - 子元素
|
||||
* @param {function} onClick - 点击事件
|
||||
* @param {string} type - 按钮类型
|
||||
* @param {string} className - 额外类名
|
||||
*/
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
children,
|
||||
onClick,
|
||||
type = 'button',
|
||||
className = '',
|
||||
...props
|
||||
}) {
|
||||
const baseClasses = 'inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'text-white bg-preline-primary hover:bg-preline-primary-hover focus:ring-preline-primary disabled:opacity-50',
|
||||
secondary: 'bg-preline-card text-preline-gray-700 border border-preline-gray-300 hover:bg-preline-gray-50 focus:ring-preline-primary disabled:bg-preline-gray-100 disabled:text-preline-gray-400',
|
||||
danger: 'bg-preline-danger text-white hover:bg-preline-danger-hover focus:ring-preline-danger disabled:opacity-50',
|
||||
ghost: 'bg-transparent text-preline-gray-600 hover:bg-preline-gray-100 focus:ring-preline-primary disabled:text-preline-gray-400',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-xs rounded-md',
|
||||
md: 'px-4 py-2.5 text-sm rounded-lg',
|
||||
lg: 'px-6 py-3 text-base rounded-lg',
|
||||
};
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
(disabled || loading) && 'cursor-not-allowed opacity-70',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,87 @@
|
||||
// Preline UI 风格卡片组件
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Preline 风格卡片容器
|
||||
*/
|
||||
export function Card({ children, className = '', ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-preline-card rounded-xl border border-preline-border shadow-sm ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片头部
|
||||
*/
|
||||
export function CardHeader({ children, className = '', action, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={`px-6 py-4 border-b border-preline-border flex items-center justify-between ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex-1">{children}</div>
|
||||
{action && <div className="ml-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片标题
|
||||
*/
|
||||
export function CardTitle({ children, className = '' }) {
|
||||
return (
|
||||
<h3 className={`text-lg font-semibold text-preline-text ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片描述
|
||||
*/
|
||||
export function CardDescription({ children, className = '' }) {
|
||||
return (
|
||||
<p className={`text-sm text-preline-text-secondary mt-1 ${className}`}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片内容
|
||||
*/
|
||||
export function CardBody({ children, className = '', ...props }) {
|
||||
return (
|
||||
<div className={`px-6 py-4 ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片底部
|
||||
*/
|
||||
export function CardFooter({ children, className = '', ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={`px-6 py-4 border-t border-preline-border flex items-center justify-end gap-3 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认导出完整卡片组件
|
||||
Card.Header = CardHeader;
|
||||
Card.Title = CardTitle;
|
||||
Card.Description = CardDescription;
|
||||
Card.Body = CardBody;
|
||||
Card.Footer = CardFooter;
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,173 @@
|
||||
// Preline UI 风格输入框组件
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 表单标签
|
||||
*/
|
||||
export function Label({ children, htmlFor, className = '', required = false }) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={`block text-sm font-medium text-preline-gray-700 mb-1.5 ${className}`}
|
||||
>
|
||||
{children}
|
||||
{required && <span className="text-preline-danger ml-0.5">*</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preline 风格输入框
|
||||
*/
|
||||
export function Input({
|
||||
type = 'text',
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
error,
|
||||
className = '',
|
||||
id,
|
||||
name,
|
||||
autoComplete,
|
||||
...props
|
||||
}) {
|
||||
const baseClasses = 'w-full px-4 py-2.5 rounded-lg border bg-preline-card text-preline-text placeholder:text-preline-gray-400 transition-all duration-200 focus:outline-none focus:ring-2';
|
||||
|
||||
const stateClasses = error
|
||||
? 'border-preline-danger-border focus:border-preline-danger focus:ring-preline-danger/20'
|
||||
: 'border-preline-gray-300 focus:border-preline-primary-500 focus:ring-preline-primary-500/20';
|
||||
|
||||
const disabledClasses = disabled ? 'bg-preline-gray-100 cursor-not-allowed opacity-70' : '';
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preline 风格文本域
|
||||
*/
|
||||
export function Textarea({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
required = false,
|
||||
error,
|
||||
rows = 4,
|
||||
className = '',
|
||||
id,
|
||||
name,
|
||||
...props
|
||||
}) {
|
||||
const baseClasses = 'w-full px-4 py-2.5 rounded-lg border bg-preline-card text-preline-text placeholder:text-preline-gray-400 transition-all duration-200 focus:outline-none focus:ring-2 resize-y';
|
||||
|
||||
const stateClasses = error
|
||||
? 'border-preline-danger-border focus:border-preline-danger focus:ring-preline-danger/20'
|
||||
: 'border-preline-gray-300 focus:border-preline-primary-500 focus:ring-preline-primary-500/20';
|
||||
|
||||
const disabledClasses = disabled ? 'bg-preline-gray-100 cursor-not-allowed opacity-70' : '';
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
rows={rows}
|
||||
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preline 风格选择框
|
||||
*/
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
required = false,
|
||||
error,
|
||||
className = '',
|
||||
id,
|
||||
name,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
const baseClasses = 'w-full px-4 py-2.5 rounded-lg border bg-preline-card text-preline-text transition-all duration-200 focus:outline-none focus:ring-2 appearance-none bg-[url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%236B7280%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.293%207.293a1%201%200%20011.414%200L10%2010.586l3.293-3.293a1%201%200%20111.414%201.414l-4%204a1%201%200%2001-1.414%200l-4-4a1%201%200%20010-1.414z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E")] bg-[length:1.5em_1.5em] bg-[right_0.5rem_center] bg-no-repeat pr-10';
|
||||
|
||||
const stateClasses = error
|
||||
? 'border-preline-danger-border focus:border-preline-danger focus:ring-preline-danger/20'
|
||||
: 'border-preline-gray-300 focus:border-preline-primary-500 focus:ring-preline-primary-500/20';
|
||||
|
||||
const disabledClasses = disabled ? 'bg-preline-gray-100 cursor-not-allowed opacity-70' : '';
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className={`${baseClasses} ${stateClasses} ${disabledClasses} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单错误提示
|
||||
*/
|
||||
export function FormError({ children }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p className="mt-1.5 text-sm text-preline-danger">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单帮助文本
|
||||
*/
|
||||
export function FormHelp({ children }) {
|
||||
return (
|
||||
<p className="mt-1.5 text-sm text-preline-text-secondary">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,121 @@
|
||||
// 侧边栏 - 尿检识别系统
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
// 导航项目配置
|
||||
const navItems = [
|
||||
{ path: '/admin/urinecheck', label: '尿检识别', icon: TestTubeIcon },
|
||||
{ path: '/admin/json-check', label: '数据识别', icon: DataIcon },
|
||||
];
|
||||
|
||||
/**
|
||||
* 侧边栏
|
||||
*/
|
||||
export function Sidebar({ user, onLogout }) {
|
||||
return (
|
||||
<aside
|
||||
className="w-64 min-h-screen fixed left-0 top-0 z-40 flex flex-col"
|
||||
style={{ backgroundColor: 'var(--color-sidebar)' }}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center px-6 border-b border-white/10">
|
||||
<NavLink to="/admin/urinecheck" className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||||
>
|
||||
<span className="text-white font-bold text-sm">U</span>
|
||||
</div>
|
||||
<span className="text-white font-semibold text-lg">尿检系统</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 mt-6 px-3 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors duration-200
|
||||
${isActive
|
||||
? 'text-white font-medium'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}
|
||||
`}
|
||||
style={({ isActive }) => ({
|
||||
backgroundColor: isActive ? 'var(--color-sidebar-active)' : undefined,
|
||||
})}
|
||||
>
|
||||
<item.icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 底部用户信息 */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-white/10 flex items-center justify-center">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{user?.username?.[0]?.toUpperCase() || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user?.username || 'User'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="p-1.5 text-gray-400 hover:text-white transition-colors"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogoutIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台布局容器
|
||||
*/
|
||||
export function AdminLayout({ children, user, onLogout }) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)' }}>
|
||||
<Sidebar user={user} onLogout={onLogout} />
|
||||
<main className="ml-64 min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Icons
|
||||
function TestTubeIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DataIcon({ className }) {
|
||||
return (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,58 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* CSS 变量定义 */
|
||||
:root {
|
||||
--color-primary: #2563EB;
|
||||
--color-primary-hover: #1D4ED8;
|
||||
--color-primary-light: #EFF6FF;
|
||||
--color-secondary: #06B6D4;
|
||||
--color-sidebar: #1E293B;
|
||||
--color-sidebar-active: #334155;
|
||||
--color-bg: #F8FAFC;
|
||||
--color-card: #FFFFFF;
|
||||
--color-border: #E2E8F0;
|
||||
--color-text: #0F172A;
|
||||
--color-text-secondary: #64748B;
|
||||
|
||||
--color-primary-50: #EFF6FF;
|
||||
--color-primary-100: #DBEAFE;
|
||||
--color-primary-200: #BFDBFE;
|
||||
--color-primary-300: #93C5FD;
|
||||
--color-primary-400: #60A5FA;
|
||||
--color-primary-500: #3B82F6;
|
||||
--color-primary-600: #2563EB;
|
||||
--color-primary-700: #1D4ED8;
|
||||
--color-primary-800: #1E40AF;
|
||||
--color-primary-900: #1E3A8A;
|
||||
--color-primary-950: #172554;
|
||||
|
||||
--color-gray-50: #F8FAFC;
|
||||
--color-gray-100: #F1F5F9;
|
||||
--color-gray-200: #E2E8F0;
|
||||
--color-gray-300: #CBD5E1;
|
||||
--color-gray-400: #94A3B8;
|
||||
--color-gray-500: #64748B;
|
||||
--color-gray-600: #475569;
|
||||
--color-gray-700: #334155;
|
||||
--color-gray-800: #1E293B;
|
||||
--color-gray-900: #0F172A;
|
||||
--color-gray-950: #020617;
|
||||
|
||||
--color-success: #22C55E;
|
||||
--color-success-bg: #DCFCE7;
|
||||
--color-success-border: #BBF7D0;
|
||||
--color-success-text: #14532D;
|
||||
--color-danger: #EF4444;
|
||||
--color-danger-bg: #FEE2E2;
|
||||
--color-danger-border: #FECACA;
|
||||
--color-danger-text: #7F1D1D;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-[var(--color-bg)] text-[var(--color-text)];
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import 'preline'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,423 @@
|
||||
// 数据识别页面 - JSON数据格式尿检
|
||||
import { useState, useRef } from 'react'
|
||||
import { AdminLayout } from '../components-preline/Sidebar'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardBody } from '../components-preline/Card'
|
||||
import { Button } from '../components-preline/Button'
|
||||
import { API_BASE_URL } from '../api/config'
|
||||
import { authApi } from '../api/auth'
|
||||
|
||||
// 项目ID → 名称映射(来自C++ ml.json的id字段)
|
||||
const ITEM_ID_MAP = {
|
||||
35: '尿胆原(URO)',
|
||||
36: '葡萄糖(GLU)',
|
||||
37: '酮体(KET)',
|
||||
34: '胆红素(BIL)',
|
||||
26: '尿蛋白(PRO)',
|
||||
39: '亚硝酸盐(NIT)',
|
||||
28: 'pH',
|
||||
27: '潜血(BLD)',
|
||||
29: '比重(SG)',
|
||||
38: '白细胞(LEU)',
|
||||
30: '抗坏血酸(VC)',
|
||||
24: '肌酐(CRE)',
|
||||
25: '尿钙(Ca)',
|
||||
23: '微量白蛋白(MA)',
|
||||
}
|
||||
|
||||
// index 等级 → 含义(通用参考)
|
||||
function getIndexLabel(id, index) {
|
||||
// NIT: 0=阴性, 1=阳性
|
||||
if (id === 39) return index === 0 ? '阴性' : index === 1 ? '阳性' : `${index}`
|
||||
// Most items: 0=阴性, 1+=阳性程度
|
||||
if (index === 0) return '阴性'
|
||||
if (index === 1) return '±'
|
||||
if (index === 2) return '+'
|
||||
if (index === 3) return '++'
|
||||
if (index === 4) return '+++'
|
||||
if (index === 5) return '++++'
|
||||
return `${index}`
|
||||
}
|
||||
|
||||
// index 对应的颜色条
|
||||
function IndexBar({ index, maxIndex = 5 }) {
|
||||
const colors = [
|
||||
'bg-green-100 border-green-400 text-green-700',
|
||||
'bg-yellow-50 border-yellow-300 text-yellow-600',
|
||||
'bg-orange-50 border-orange-300 text-orange-600',
|
||||
'bg-red-50 border-red-300 text-red-600',
|
||||
'bg-red-100 border-red-400 text-red-700',
|
||||
'bg-red-200 border-red-500 text-red-800',
|
||||
]
|
||||
const cls = colors[Math.min(index, colors.length - 1)]
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium border ${cls}`}>
|
||||
{getIndexLabel(null, index)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function JsonDataCheck() {
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [fileContent, setFileContent] = useState(null) // parsed JSON
|
||||
const [jsonText, setJsonText] = useState('') // raw text for preview
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
|
||||
const handleLogout = async () => {
|
||||
localStorage.removeItem('token')
|
||||
await authApi.logout()
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
// 文件选择
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 检查格式
|
||||
if (!file.name.endsWith('.txt') && !file.name.endsWith('.json')) {
|
||||
alert('仅支持 .txt 和 .json 格式')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('文件大小不能超过 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
setFileName(file.name)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target.result
|
||||
setJsonText(text)
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
setFileContent(parsed)
|
||||
} catch (err) {
|
||||
setError('JSON 解析失败: ' + err.message)
|
||||
setFileContent(null)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
// 发起识别
|
||||
const handleAnalyze = async () => {
|
||||
if (!fileContent) {
|
||||
alert('请先选择有效的数据文件')
|
||||
return
|
||||
}
|
||||
|
||||
setAnalyzing(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/urinecheck/analyze-json`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fileContent),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.detail || `请求失败 (${response.status})`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status !== '0') {
|
||||
setError(data.message || '识别失败')
|
||||
setResult(data)
|
||||
return
|
||||
}
|
||||
|
||||
setResult(data)
|
||||
} catch (err) {
|
||||
setError('网络错误: ' + err.message)
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (!file) return
|
||||
if (fileInputRef.current) {
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(file)
|
||||
fileInputRef.current.files = dt.files
|
||||
handleFileSelect({ target: { files: dt.files } })
|
||||
}
|
||||
}
|
||||
const handleDragOver = (e) => e.preventDefault()
|
||||
|
||||
// 统计信息
|
||||
const getDataPreview = () => {
|
||||
if (!fileContent?.data) return null
|
||||
const frames = fileContent.data
|
||||
const frameCount = frames.length
|
||||
const itemCount = frames[0]?.length || 0
|
||||
const color14i = fileContent.extend?.color_14i || fileContent.extend?.type || '?'
|
||||
return { frameCount, itemCount, color14i }
|
||||
}
|
||||
|
||||
const preview = getDataPreview()
|
||||
const resultItems = result?.data?.slice().sort((a, b) => a.id - b.id) || []
|
||||
|
||||
return (
|
||||
<AdminLayout user={user} onLogout={handleLogout}>
|
||||
<div className="p-6 w-full">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">数据识别</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
上传手机端采集的色块颜色数据(JSON格式),使用ML模型进行尿检结果识别
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
value格式: H,S,V,L,A,B 每帧14项
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 左侧:上传区域 */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept=".txt,.json"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 拖拽/点击上传区 */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer
|
||||
hover:border-blue-400 hover:bg-blue-50/50 transition-all duration-200"
|
||||
>
|
||||
{preview ? (
|
||||
<div className="space-y-3">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto">
|
||||
<svg className="w-8 h-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{fileName}</p>
|
||||
<div className="flex justify-center gap-3 text-xs">
|
||||
<span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded">
|
||||
帧数: {preview.frameCount}
|
||||
</span>
|
||||
<span className="bg-purple-50 text-purple-600 px-2 py-0.5 rounded">
|
||||
每帧: {preview.itemCount} 项
|
||||
</span>
|
||||
<span className="bg-green-50 text-green-600 px-2 py-0.5 rounded">
|
||||
试纸类型: {preview.color14i}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">点击可更换文件</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 py-8">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto">
|
||||
<svg className="w-8 h-8 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-700 font-medium">拖拽数据文件到此处,或点击上传</p>
|
||||
<p className="text-sm text-gray-400 mt-1">支持 .txt / .json,最大 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 识别按钮 */}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={handleAnalyze}
|
||||
loading={analyzing}
|
||||
disabled={!fileContent || analyzing}
|
||||
className="w-full"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
{analyzing ? '识别中...' : '开始识别'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:识别结果 */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>识别结果</CardTitle>
|
||||
<CardDescription>
|
||||
{analyzing
|
||||
? '正在 ML 推理...'
|
||||
: result?.status === '0'
|
||||
? `识别完成 · 共 ${result.data?.length || 0} 项`
|
||||
: result
|
||||
? `状态: ${result.status}`
|
||||
: '等待上传数据文件并点击识别'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* 错误信息 */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800">识别失败</p>
|
||||
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果概要 */}
|
||||
{result?.status === '0' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoBadge label="试纸类型" value={`paper_id=${result.paper_id}`} color="blue" />
|
||||
<InfoBadge label="项目类型" value={`project_id=${result.project_id}`} color="green" />
|
||||
<InfoBadge label="识别项数" value={`${result.data?.length || 0} 项`} color="purple" />
|
||||
<InfoBadge label="评分" value={result.score} color="green" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 等待状态 */}
|
||||
{!result && !error && !analyzing && (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500">上传数据文件后开始识别</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{analyzing && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="ml-3 text-gray-500">正在 ML 模型推理...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:识别结果表格 */}
|
||||
{result?.status === '0' && resultItems.length > 0 && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>尿检结果</CardTitle>
|
||||
<CardDescription>
|
||||
基于 RTrees ML 模型推理 · 多帧投票众数 · PRO/MAU & SG/pH 关联修正
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">ID</th>
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">项目名称</th>
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">等级</th>
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resultItems.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<td className="py-2.5 px-3 text-sm font-mono text-gray-500">{item.id}</td>
|
||||
<td className="py-2.5 px-3 text-sm font-medium text-gray-700">
|
||||
{ITEM_ID_MAP[item.id] || `项目${item.id}`}
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="text-sm font-mono text-gray-600">{item.index}</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{item.id === 29 ? (
|
||||
// SG 特殊显示
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{1.000 + item.index * 0.005}
|
||||
</span>
|
||||
) : item.id === 28 ? (
|
||||
// pH 特殊显示
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
pH {5.0 + item.index * 0.5}
|
||||
</span>
|
||||
) : (
|
||||
<IndexBar index={item.index} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// 小信息标签
|
||||
function InfoBadge({ label, value, color }) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
green: 'bg-green-50 border-green-200 text-green-700',
|
||||
purple: 'bg-purple-50 border-purple-200 text-purple-700',
|
||||
yellow: 'bg-yellow-50 border-yellow-200 text-yellow-700',
|
||||
red: 'bg-red-50 border-red-200 text-red-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2.5 ${colorClasses[color] || colorClasses.blue}`}>
|
||||
<p className="text-xs font-medium opacity-70">{label}</p>
|
||||
<p className="text-sm font-semibold mt-0.5">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonDataCheck
|
||||
@@ -0,0 +1,149 @@
|
||||
// 登录页面 - 尿检识别系统
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card } from '../components-preline/Card'
|
||||
import { Button } from '../components-preline/Button'
|
||||
import { Input, Label } from '../components-preline/Input'
|
||||
import { authApi } from '../api/auth'
|
||||
|
||||
export function Login() {
|
||||
const [username, setUsername] = useState('admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 自动检测:如果已有本地 token,或 SSO Cookie 已登录,则跳转
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
navigate('/admin/urinecheck')
|
||||
return
|
||||
}
|
||||
// 无本地 token 时,尝试用 SSO Cookie 调用 /api/auth/me
|
||||
authApi.getMe()
|
||||
.then(() => navigate('/admin/urinecheck'))
|
||||
.catch(() => {
|
||||
// SSO 也未登录,留在登录页
|
||||
})
|
||||
}, [navigate])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await authApi.login(username, password)
|
||||
localStorage.setItem('token', response.data.access_token)
|
||||
localStorage.setItem('user', JSON.stringify({ username }))
|
||||
navigate('/admin/urinecheck')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
// 跳转到 SSO 中心,携带 redirect 参数
|
||||
const redirect = window.location.origin
|
||||
window.location.href = `http://129.211.173.87:9000/?redirect=${encodeURIComponent(redirect + '/admin/urinecheck')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-600 rounded-2xl shadow-lg mb-4">
|
||||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">尿检识别系统</h1>
|
||||
<p className="text-gray-500 mt-1">后台管理</p>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl">
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">管理员登录</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">请输入您的账号密码</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-red-700">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<Label htmlFor="username" required>用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" required>密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full mt-2"
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* SSO 登录入口 */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-3 bg-white text-gray-500">其他登录方式</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSSOLogin}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-indigo-200 rounded-lg text-indigo-600 hover:bg-indigo-50 transition-colors font-medium"
|
||||
>
|
||||
🔐 SSO 统一登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-4 bg-gray-50 border-t border-gray-100 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
© {new Date().getFullYear()} 尿检识别系统
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
@@ -0,0 +1,378 @@
|
||||
// 尿检识别页面 - Preline UI 风格
|
||||
import { useState, useRef } from 'react'
|
||||
import { AdminLayout } from '../components-preline/Sidebar'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardBody } from '../components-preline/Card'
|
||||
import { Button } from '../components-preline/Button'
|
||||
import { API_BASE_URL } from '../api/config'
|
||||
import { authApi } from '../api/auth'
|
||||
|
||||
// 颜色小圆点
|
||||
function ColorSwatch({ r, g, b }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded border border-gray-300 flex-shrink-0"
|
||||
style={{ backgroundColor: `rgb(${r},${g},${b})` }}
|
||||
title={`RGB(${r},${g},${b})`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 色块颜色行
|
||||
function ColorRow({ item, index }) {
|
||||
return (
|
||||
<tr className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<td className="py-2 px-3 text-center text-sm text-gray-500 font-mono">{item.id}</td>
|
||||
<td className="py-2 px-3 text-sm font-medium text-gray-700 whitespace-nowrap">
|
||||
{item.name || `项目${item.id}`}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<ColorSwatch r={item.rgb.r} g={item.rgb.g} b={item.rgb.b} />
|
||||
</td>
|
||||
<td className="py-2 px-3 text-sm font-mono text-gray-600">
|
||||
{item.rgb.r.toFixed(0)}, {item.rgb.g.toFixed(0)}, {item.rgb.b.toFixed(0)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-sm font-mono text-gray-600">
|
||||
{item.hsv.h.toFixed(0)}, {item.hsv.s.toFixed(0)}, {item.hsv.v.toFixed(0)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-sm font-mono text-gray-600">
|
||||
{item.lab.l.toFixed(0)}, {item.lab.a.toFixed(0)}, {item.lab.b.toFixed(0)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function UrineCheck() {
|
||||
const [preview, setPreview] = useState(null) // 预览图 data URL
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [result, setResult] = useState(null) // 识别结果
|
||||
const [error, setError] = useState(null) // 错误信息
|
||||
const fileInputRef = useRef(null)
|
||||
const [fileName, setFileName] = useState('')
|
||||
|
||||
// 获取当前用户
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
|
||||
const handleLogout = async () => {
|
||||
localStorage.removeItem('token')
|
||||
await authApi.logout()
|
||||
localStorage.removeItem('user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
// 文件选择 → 本地预览
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 检查格式
|
||||
const allowed = ['image/jpeg', 'image/png']
|
||||
if (!allowed.includes(file.type)) {
|
||||
alert('仅支持 JPG 和 PNG 格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查大小
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('文件大小不能超过 10MB')
|
||||
return
|
||||
}
|
||||
|
||||
setFileName(file.name)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => setPreview(ev.target.result)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
// 开始识别
|
||||
const handleAnalyze = async () => {
|
||||
const file = fileInputRef.current?.files?.[0]
|
||||
if (!file) {
|
||||
alert('请先选择试纸图片')
|
||||
return
|
||||
}
|
||||
|
||||
setAnalyzing(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/urinecheck/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.detail || `请求失败 (${response.status})`)
|
||||
return
|
||||
}
|
||||
|
||||
setResult(data)
|
||||
if (!data.success) {
|
||||
setError(data.error_message || '识别失败')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('网络错误: ' + err.message)
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽上传
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (!file) return
|
||||
if (fileInputRef.current) {
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(file)
|
||||
fileInputRef.current.files = dt.files
|
||||
handleFileSelect({ target: { files: dt.files } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e) => e.preventDefault()
|
||||
|
||||
return (
|
||||
<AdminLayout user={user} onLogout={handleLogout}>
|
||||
<div className="p-6 w-full">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">尿检识别</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
上传尿常规试纸图片,自动识别各项目色块颜色值
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
支持 8联 / 11联 / 14联试纸 (S型 & L型)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 左侧:上传区域 */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="image/jpeg,image/png"
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 拖拽/点击上传区 */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer
|
||||
hover:border-blue-400 hover:bg-blue-50/50 transition-all duration-200"
|
||||
>
|
||||
{preview ? (
|
||||
<div className="space-y-3">
|
||||
<img
|
||||
src={preview}
|
||||
alt="试纸预览"
|
||||
className="max-h-64 mx-auto rounded-lg shadow-sm object-contain"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">{fileName}</p>
|
||||
<p className="text-xs text-gray-400">点击可更换图片</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 py-8">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto">
|
||||
<svg className="w-8 h-8 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-700 font-medium">拖拽图片到此处,或点击上传</p>
|
||||
<p className="text-sm text-gray-400 mt-1">支持 JPG / PNG,最大 10MB</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 识别按钮 */}
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={handleAnalyze}
|
||||
loading={analyzing}
|
||||
disabled={!preview || analyzing}
|
||||
className="w-full"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
{analyzing ? '识别中...' : '开始识别'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:识别结果 */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>识别结果</CardTitle>
|
||||
<CardDescription>
|
||||
{analyzing
|
||||
? '正在分析中...'
|
||||
: result
|
||||
? `识别完成 · 共 ${result.item_count} 项色块`
|
||||
: '等待上传图片并点击识别'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* 错误信息 */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800">识别失败</p>
|
||||
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基本信息 */}
|
||||
{result?.success && (
|
||||
<div className="space-y-4">
|
||||
{/* 试纸信息卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoBadge label="试纸规格" value={result.paper_type} color="blue" />
|
||||
<InfoBadge label="定位块颜色" value={result.locator_color} color="green" />
|
||||
<InfoBadge label="色块数量" value={`${result.item_count} 项`} color="purple" />
|
||||
<InfoBadge
|
||||
label="清晰度"
|
||||
value={['清晰', '轻微模糊', '严重模糊'][result.blur_level] || '未知'}
|
||||
color={result.blur_level === 0 ? 'green' : result.blur_level === 1 ? 'yellow' : 'red'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标注图:色块中心点红圈标记 */}
|
||||
{result.annotated_image_base64 && (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-3 py-2 bg-gray-50 border-b border-gray-200">
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
📍 色块中心点定位(红圈标记)
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-100 flex items-center justify-center p-2">
|
||||
<img
|
||||
src={`data:image/jpeg;base64,${result.annotated_image_base64}`}
|
||||
alt="色块定位标注图"
|
||||
className="max-w-full max-h-64 object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 等待状态 */}
|
||||
{!result && !error && !analyzing && (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-500">上传试纸图片后开始识别</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{analyzing && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="ml-3 text-gray-500">正在分析试纸图片...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:颜色值表格 */}
|
||||
{result?.success && result.colors && result.colors.length > 0 && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<CardTitle>色块颜色值</CardTitle>
|
||||
<CardDescription>
|
||||
id=0 靠近小定位块,向大定位块方向递增 · 共 {result.colors.length} 项
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-gray-200 bg-gray-50">
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">#</th>
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">项目</th>
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">色块</th>
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">RGB</th>
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">HSV</th>
|
||||
<th className="py-2.5 px-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">LAB</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.colors.map((item, idx) => (
|
||||
<ColorRow key={idx} item={item} index={idx} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// 小信息标签
|
||||
function InfoBadge({ label, value, color }) {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
green: 'bg-green-50 border-green-200 text-green-700',
|
||||
purple: 'bg-purple-50 border-purple-200 text-purple-700',
|
||||
yellow: 'bg-yellow-50 border-yellow-200 text-yellow-700',
|
||||
red: 'bg-red-50 border-red-200 text-red-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2.5 ${colorClasses[color] || colorClasses.blue}`}>
|
||||
<p className="text-xs font-medium opacity-70">{label}</p>
|
||||
<p className="text-sm font-semibold mt-0.5">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UrineCheck
|
||||
@@ -0,0 +1,60 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"./node_modules/preline/dist/*.js",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'preline': {
|
||||
'primary': 'var(--color-primary, #2563EB)',
|
||||
'primary-hover': 'var(--color-primary-hover, #1D4ED8)',
|
||||
'primary-light': 'var(--color-primary-light, #EFF6FF)',
|
||||
'secondary': 'var(--color-secondary, #06B6D4)',
|
||||
'sidebar': 'var(--color-sidebar, #1E293B)',
|
||||
'sidebar-active': 'var(--color-sidebar-active, #334155)',
|
||||
'bg': 'var(--color-bg, #F8FAFC)',
|
||||
'card': 'var(--color-card, #FFFFFF)',
|
||||
'border': 'var(--color-border, #E2E8F0)',
|
||||
'text': 'var(--color-text, #0F172A)',
|
||||
'text-secondary': 'var(--color-text-secondary, #64748B)',
|
||||
'primary-50': 'var(--color-primary-50, #EFF6FF)',
|
||||
'primary-100': 'var(--color-primary-100, #DBEAFE)',
|
||||
'primary-200': 'var(--color-primary-200, #BFDBFE)',
|
||||
'primary-300': 'var(--color-primary-300, #93C5FD)',
|
||||
'primary-400': 'var(--color-primary-400, #60A5FA)',
|
||||
'primary-500': 'var(--color-primary-500, #3B82F6)',
|
||||
'primary-600': 'var(--color-primary-600, #2563EB)',
|
||||
'primary-700': 'var(--color-primary-700, #1D4ED8)',
|
||||
'primary-800': 'var(--color-primary-800, #1E40AF)',
|
||||
'primary-900': 'var(--color-primary-900, #1E3A8A)',
|
||||
'primary-950': 'var(--color-primary-950, #172554)',
|
||||
'gray-50': 'var(--color-gray-50, #F8FAFC)',
|
||||
'gray-100': 'var(--color-gray-100, #F1F5F9)',
|
||||
'gray-200': 'var(--color-gray-200, #E2E8F0)',
|
||||
'gray-300': 'var(--color-gray-300, #CBD5E1)',
|
||||
'gray-400': 'var(--color-gray-400, #94A3B8)',
|
||||
'gray-500': 'var(--color-gray-500, #64748B)',
|
||||
'gray-600': 'var(--color-gray-600, #475569)',
|
||||
'gray-700': 'var(--color-gray-700, #334155)',
|
||||
'gray-800': 'var(--color-gray-800, #1E293B)',
|
||||
'gray-900': 'var(--color-gray-900, #0F172A)',
|
||||
'gray-950': 'var(--color-gray-950, #020617)',
|
||||
'success': 'var(--color-success, #22C55E)',
|
||||
'success-bg': 'var(--color-success-bg, #DCFCE7)',
|
||||
'success-border': 'var(--color-success-border, #BBF7D0)',
|
||||
'success-text': 'var(--color-success-text, #14532D)',
|
||||
'danger': 'var(--color-danger, #EF4444)',
|
||||
'danger-bg': 'var(--color-danger-bg, #FEE2E2)',
|
||||
'danger-border': 'var(--color-danger-border, #FECACA)',
|
||||
'danger-text': 'var(--color-danger-text, #7F1D1D)',
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8905,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8904',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user