This commit is contained in:
Ubuntu
2026-07-03 16:58:28 +08:00
commit 32b43f85bd
6240 changed files with 1987678 additions and 0 deletions
+106
View File
@@ -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)
+63
View File
@@ -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)
+27
View File
@@ -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()
+431
View File
@@ -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
+215
View File
@@ -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]
+16
View File
@@ -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)
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
from . import auth, urinecheck
+155
View File
@@ -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")
+107
View File
@@ -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
+33
View File
@@ -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
+22
View File
@@ -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` | **修改** | 启动时初始化识别器 |
+459
View File
@@ -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/ 可写 |
---
**请蔡叔叔审阅以上方案,确认后我开始写代码。有问题随时指出。**
+13
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+70
View File
@@ -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
+23
View File
@@ -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
})
}
}
+42
View File
@@ -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;
+58
View File
@@ -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;
}
}
+11
View File
@@ -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
+149
View File
@@ -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
+60
View File
@@ -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'),
],
}
+16
View File
@@ -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,
}
}
}
})