feat: 初始化孕育无忧全栈项目基础代码结构
完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
39
backend/app/config.py
Normal file
39
backend/app/config.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# MongoDB
|
||||
mongodb_url: str = "mongodb://localhost:27017"
|
||||
mongodb_db_name: str = "pregnancy_report"
|
||||
|
||||
# JWT
|
||||
jwt_secret_key: str = "change-this-secret-key"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 10080 # 7 days
|
||||
|
||||
# WeChat
|
||||
wechat_app_id: str = ""
|
||||
wechat_app_secret: str = ""
|
||||
|
||||
# AI API (OpenAI 兼容接口,如硅基流动)
|
||||
ai_api_key: str = ""
|
||||
ai_base_url: str = "https://api.siliconflow.cn/v1"
|
||||
ai_vision_model: str = "Qwen/Qwen2.5-VL-72B-Instruct"
|
||||
ai_text_model: str = "Qwen/Qwen2.5-72B-Instruct" # 使用更快的72B模型
|
||||
|
||||
# Upload
|
||||
upload_dir: str = "./uploads"
|
||||
max_file_size: int = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "*" # 多个域名用逗号分隔,如 https://a.com,https://b.com
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
debug: bool = True
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
38
backend/app/database.py
Normal file
38
backend/app/database.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import certifi
|
||||
|
||||
from beanie import init_beanie
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
from app.models.report import Report
|
||||
from app.models.analysis import Analysis
|
||||
from app.models.chat import ChatSession
|
||||
from app.models.checkup import CheckupPlan
|
||||
|
||||
|
||||
async def init_db():
|
||||
use_mock = os.environ.get("USE_MONGOMOCK", "").lower() in ("1", "true", "yes")
|
||||
|
||||
if use_mock:
|
||||
# 使用 mongomock 内存数据库(无需安装 MongoDB)
|
||||
from mongomock_motor import AsyncMongoMockClient
|
||||
client = AsyncMongoMockClient()
|
||||
else:
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
# Only use TLS for Atlas (mongodb+srv://) connections
|
||||
if settings.mongodb_url.startswith("mongodb+srv://") or "mongodb.net" in settings.mongodb_url:
|
||||
client = AsyncIOMotorClient(settings.mongodb_url, tlsCAFile=certifi.where())
|
||||
else:
|
||||
client = AsyncIOMotorClient(settings.mongodb_url)
|
||||
|
||||
await init_beanie(
|
||||
database=client[settings.mongodb_db_name],
|
||||
document_models=[
|
||||
User,
|
||||
Report,
|
||||
Analysis,
|
||||
ChatSession,
|
||||
CheckupPlan,
|
||||
],
|
||||
)
|
||||
114
backend/app/main.py
Normal file
114
backend/app/main.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.routers import (
|
||||
auth,
|
||||
user,
|
||||
report,
|
||||
analysis,
|
||||
chat,
|
||||
health_records,
|
||||
calendar,
|
||||
doctor,
|
||||
share,
|
||||
)
|
||||
|
||||
# 日志配置
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("孕育无忧")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("服务启动中...")
|
||||
await init_db()
|
||||
os.makedirs(settings.upload_dir, exist_ok=True)
|
||||
logger.info("服务启动完成 http://%s:%s", settings.host, settings.port)
|
||||
yield
|
||||
logger.info("服务关闭")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="孕育无忧 API",
|
||||
description="孕期健康检查报告解读后端服务",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS 配置
|
||||
cors_origins = (
|
||||
["*"]
|
||||
if settings.cors_origins == "*"
|
||||
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# 请求日志中间件
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
duration = round((time.time() - start) * 1000)
|
||||
logger.info(
|
||||
"%s %s → %s (%dms)",
|
||||
request.method,
|
||||
request.url.path,
|
||||
response.status_code,
|
||||
duration,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
# 全局异常处理
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error("未处理异常 %s %s: %s", request.method, request.url.path, exc, exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "服务器内部错误,请稍后重试"},
|
||||
)
|
||||
|
||||
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
|
||||
app.include_router(user.router, prefix="/api/user", tags=["用户"])
|
||||
app.include_router(report.router, prefix="/api/report", tags=["报告"])
|
||||
app.include_router(analysis.router, prefix="/api/analysis", tags=["AI解读"])
|
||||
app.include_router(chat.router, prefix="/api/chat", tags=["AI对话"])
|
||||
app.include_router(health_records.router, prefix="/api/health-records", tags=["健康档案"])
|
||||
app.include_router(calendar.router, prefix="/api/calendar", tags=["产检日历"])
|
||||
app.include_router(doctor.router, prefix="/api/doctor", tags=["专家咨询"])
|
||||
app.include_router(share.router, prefix="/api/share", tags=["分享"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "service": "孕育无忧 API"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def web_demo():
|
||||
"""Web 体验页面"""
|
||||
return FileResponse(
|
||||
os.path.join(os.path.dirname(__file__), "static", "index.html")
|
||||
)
|
||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
36
backend/app/middleware/auth.py
Normal file
36
backend/app/middleware/auth.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def create_access_token(user_id: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
to_encode = {"sub": user_id, "exp": expire}
|
||||
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]
|
||||
)
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证信息")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token已过期或无效")
|
||||
|
||||
user = await User.get(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
return user
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
60
backend/app/models/analysis.py
Normal file
60
backend/app/models/analysis.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from beanie import Document, PydanticObjectId
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class IndicatorInterpretation(BaseModel):
|
||||
"""单项指标解读"""
|
||||
name: str
|
||||
nickname: str = "" # 白话名称,如"氧气快递员"
|
||||
value: str = ""
|
||||
status: str = "normal"
|
||||
what_is_it: str = "" # 通俗解释
|
||||
impact: str = "" # 对宝宝的影响
|
||||
suggestion: str = "" # 行动建议
|
||||
recheck_reminder: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryInterpretation(BaseModel):
|
||||
"""分类解读(如血液指标、激素水平)"""
|
||||
category: str
|
||||
status: str = "normal"
|
||||
indicators: list[IndicatorInterpretation] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ActionItem(BaseModel):
|
||||
"""行动建议"""
|
||||
content: str
|
||||
priority: str = "medium" # high / medium / low
|
||||
category: str = "" # 饮食 / 运动 / 就医 / 复查
|
||||
|
||||
|
||||
class Analysis(Document):
|
||||
report_id: PydanticObjectId
|
||||
user_id: PydanticObjectId
|
||||
|
||||
# Overall assessment
|
||||
overall_status: str = "normal" # normal / attention / warning
|
||||
risk_level: str = "low" # low / medium / high
|
||||
overall_summary: str = "" # 整体评估文案
|
||||
emotional_summary: str = "" # 情感化总结,如"宝宝发育良好"
|
||||
|
||||
# Detailed interpretations
|
||||
categories: list[CategoryInterpretation] = Field(default_factory=list)
|
||||
|
||||
# Action items
|
||||
action_items: list[ActionItem] = Field(default_factory=list)
|
||||
|
||||
# Tips
|
||||
tips: list[str] = Field(default_factory=list)
|
||||
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class Settings:
|
||||
name = "analyses"
|
||||
indexes = [
|
||||
"user_id",
|
||||
"report_id",
|
||||
]
|
||||
24
backend/app/models/chat.py
Normal file
24
backend/app/models/chat.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from beanie import Document, PydanticObjectId
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str # user / assistant
|
||||
content: str
|
||||
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ChatSession(Document):
|
||||
user_id: PydanticObjectId
|
||||
report_id: PydanticObjectId
|
||||
messages: list[ChatMessage] = Field(default_factory=list)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class Settings:
|
||||
name = "chat_sessions"
|
||||
indexes = [
|
||||
[("user_id", 1), ("report_id", 1)],
|
||||
]
|
||||
30
backend/app/models/checkup.py
Normal file
30
backend/app/models/checkup.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from beanie import Document, PydanticObjectId
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CheckupItem(BaseModel):
|
||||
title: str
|
||||
week: int
|
||||
date: Optional[str] = None
|
||||
items: list[str] = Field(default_factory=list)
|
||||
preparations: list[str] = Field(default_factory=list)
|
||||
notes: str = ""
|
||||
status: str = "upcoming" # upcoming / completed / missed
|
||||
importance: str = "medium" # high / medium / low
|
||||
report_ids: list[str] = Field(default_factory=list) # linked report IDs
|
||||
|
||||
|
||||
class CheckupPlan(Document):
|
||||
user_id: PydanticObjectId
|
||||
checkups: list[CheckupItem] = Field(default_factory=list)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class Settings:
|
||||
name = "checkup_plans"
|
||||
indexes = [
|
||||
"user_id",
|
||||
]
|
||||
50
backend/app/models/report.py
Normal file
50
backend/app/models/report.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from beanie import Document, PydanticObjectId
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DataItem(BaseModel):
|
||||
"""单个检查指标"""
|
||||
name: str
|
||||
value: str
|
||||
unit: str = ""
|
||||
reference_range: str = ""
|
||||
status: str = "normal" # normal / high / low
|
||||
category: str = "" # 血液检查 / 激素水平 / 尿常规 etc.
|
||||
|
||||
|
||||
class Report(Document):
|
||||
user_id: PydanticObjectId
|
||||
report_type: str = "" # 血常规 / B超 / 尿常规 etc.
|
||||
pregnancy_week: Optional[int] = None
|
||||
report_date: Optional[str] = None # 报告日期 YYYY-MM-DD
|
||||
|
||||
# Images
|
||||
image_urls: list[str] = Field(default_factory=list)
|
||||
|
||||
# OCR results
|
||||
ocr_raw_text: Optional[str] = None
|
||||
data_items: list[DataItem] = Field(default_factory=list)
|
||||
|
||||
# User additions
|
||||
symptoms: Optional[list[str]] = None # 自觉症状
|
||||
notes: Optional[str] = None # 备注
|
||||
|
||||
# Privacy fields (auto-masked)
|
||||
patient_name: Optional[str] = None
|
||||
patient_id_masked: Optional[str] = None
|
||||
|
||||
# Status
|
||||
status: str = "pending" # pending / verified / analyzed
|
||||
overall_status: str = "normal" # normal / attention / warning
|
||||
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class Settings:
|
||||
name = "reports"
|
||||
indexes = [
|
||||
"user_id",
|
||||
]
|
||||
55
backend/app/models/user.py
Normal file
55
backend/app/models/user.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from beanie import Document
|
||||
from pymongo import IndexModel, ASCENDING
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EmergencyContact(BaseModel):
|
||||
name: str = ""
|
||||
relation: str = ""
|
||||
phone: str = ""
|
||||
|
||||
|
||||
class User(Document):
|
||||
openid: str = Field(..., unique=True)
|
||||
union_id: Optional[str] = None
|
||||
session_key: Optional[str] = None
|
||||
|
||||
# Basic info
|
||||
name: str = ""
|
||||
phone: Optional[str] = None
|
||||
age: Optional[int] = None
|
||||
height: Optional[float] = None # cm
|
||||
pre_pregnancy_weight: Optional[float] = None # kg
|
||||
current_weight: Optional[float] = None # kg
|
||||
blood_type: Optional[str] = None
|
||||
allergies: list[str] = Field(default_factory=list)
|
||||
medical_history: list[str] = Field(default_factory=list)
|
||||
|
||||
# Pregnancy info
|
||||
last_period_date: Optional[datetime] = None
|
||||
due_date: Optional[datetime] = None
|
||||
pregnancy_week: Optional[int] = None
|
||||
pregnancy_day: Optional[int] = None
|
||||
multiple_pregnancy: Optional[str] = None # 单胎/双胎/三胎+
|
||||
pregnancy_count: Optional[int] = None # 第几次怀孕
|
||||
|
||||
emergency_contact: EmergencyContact = Field(default_factory=EmergencyContact)
|
||||
|
||||
# Status
|
||||
is_onboarded: bool = False
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class Settings:
|
||||
name = "users"
|
||||
indexes = [
|
||||
IndexModel(
|
||||
[("phone", ASCENDING)],
|
||||
unique=True,
|
||||
partialFilterExpression={"phone": {"$type": "string"}},
|
||||
),
|
||||
]
|
||||
9
backend/app/routers/__init__.py
Normal file
9
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app.routers.auth import router as auth_router
|
||||
from app.routers.user import router as user_router
|
||||
from app.routers.report import router as report_router
|
||||
from app.routers.analysis import router as analysis_router
|
||||
from app.routers.chat import router as chat_router
|
||||
from app.routers.health_records import router as health_records_router
|
||||
from app.routers.calendar import router as calendar_router
|
||||
from app.routers.doctor import router as doctor_router
|
||||
from app.routers.share import router as share_router
|
||||
147
backend/app/routers/analysis.py
Normal file
147
backend/app/routers/analysis.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.analysis import (
|
||||
Analysis,
|
||||
CategoryInterpretation,
|
||||
IndicatorInterpretation,
|
||||
ActionItem,
|
||||
)
|
||||
from app.models.report import Report
|
||||
from app.models.user import User
|
||||
from app.services.ai_service import analyze_report
|
||||
from app.routers.calendar import link_report_to_checkup
|
||||
from app.utils.helpers import parse_object_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{report_id}/generate")
|
||||
async def generate_analysis(
|
||||
report_id: str, user: User = Depends(get_current_user)
|
||||
):
|
||||
"""生成 AI 解读"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
|
||||
# Check if analysis already exists
|
||||
existing = await Analysis.find_one(Analysis.report_id == report.id)
|
||||
if existing:
|
||||
return _analysis_to_dict(existing)
|
||||
|
||||
# Call Claude API
|
||||
try:
|
||||
result = await analyze_report(report, user)
|
||||
except Exception:
|
||||
logger.exception("AI 解读失败 report_id=%s", report_id)
|
||||
raise HTTPException(status_code=502, detail="AI 解读服务暂时不可用,请稍后重试")
|
||||
|
||||
# Save analysis
|
||||
categories = []
|
||||
for cat in result.get("categories", []):
|
||||
indicators = [
|
||||
IndicatorInterpretation(**ind) for ind in cat.get("indicators", [])
|
||||
]
|
||||
categories.append(
|
||||
CategoryInterpretation(
|
||||
category=cat.get("category", ""),
|
||||
status=cat.get("status", "normal"),
|
||||
indicators=indicators,
|
||||
)
|
||||
)
|
||||
|
||||
action_items = [ActionItem(**a) for a in result.get("action_items", [])]
|
||||
|
||||
analysis = Analysis(
|
||||
report_id=report.id,
|
||||
user_id=user.id,
|
||||
overall_status=result.get("overall_status", "normal"),
|
||||
risk_level=result.get("risk_level", "low"),
|
||||
overall_summary=result.get("overall_summary", ""),
|
||||
emotional_summary=result.get("emotional_summary", ""),
|
||||
categories=categories,
|
||||
action_items=action_items,
|
||||
tips=result.get("tips", []),
|
||||
)
|
||||
await analysis.insert()
|
||||
|
||||
# Update report status
|
||||
report.status = "analyzed"
|
||||
report.overall_status = analysis.overall_status
|
||||
report.updated_at = datetime.now(timezone.utc)
|
||||
await report.save()
|
||||
|
||||
# Auto-link report to the nearest calendar checkup
|
||||
if report.pregnancy_week:
|
||||
try:
|
||||
await link_report_to_checkup(user.id, str(report.id), report.pregnancy_week)
|
||||
except Exception:
|
||||
logger.warning("自动关联日历失败 report_id=%s", report_id)
|
||||
|
||||
return _analysis_to_dict(analysis)
|
||||
|
||||
|
||||
@router.get("/{report_id}")
|
||||
async def get_analysis(report_id: str, user: User = Depends(get_current_user)):
|
||||
"""获取报告的 AI 解读结果"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
|
||||
analysis = await Analysis.find_one(Analysis.report_id == report.id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="暂无解读结果,请先生成")
|
||||
|
||||
return _analysis_to_dict(analysis)
|
||||
|
||||
|
||||
def _analysis_to_dict(analysis: Analysis) -> dict:
|
||||
return {
|
||||
"id": str(analysis.id),
|
||||
"report_id": str(analysis.report_id),
|
||||
"overall_status": analysis.overall_status,
|
||||
"risk_level": analysis.risk_level,
|
||||
"overall_summary": analysis.overall_summary,
|
||||
"emotional_summary": analysis.emotional_summary,
|
||||
"categories": [
|
||||
{
|
||||
"category": cat.category,
|
||||
"status": cat.status,
|
||||
"indicators": [
|
||||
{
|
||||
"name": ind.name,
|
||||
"nickname": ind.nickname,
|
||||
"value": ind.value,
|
||||
"unit": "",
|
||||
"reference_range": "",
|
||||
"status": ind.status,
|
||||
"what_is_it": ind.what_is_it,
|
||||
"baby_impact": ind.impact,
|
||||
"suggestion": ind.suggestion,
|
||||
"follow_up": ind.recheck_reminder or "",
|
||||
}
|
||||
for ind in cat.indicators
|
||||
],
|
||||
}
|
||||
for cat in analysis.categories
|
||||
],
|
||||
"action_items": [
|
||||
{
|
||||
"priority": a.priority,
|
||||
"title": a.content,
|
||||
"description": a.category,
|
||||
"timeframe": "",
|
||||
}
|
||||
for a in analysis.action_items
|
||||
],
|
||||
"tips": analysis.tips,
|
||||
"created_at": analysis.created_at.isoformat(),
|
||||
}
|
||||
87
backend/app/routers/auth.py
Normal file
87
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import WechatLoginRequest, PhoneLoginRequest, TokenResponse
|
||||
from app.middleware.auth import create_access_token
|
||||
from app.services.wechat import code_to_session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/demo-login", response_model=TokenResponse)
|
||||
async def demo_login():
|
||||
"""Web 体验用的演示登录,每次创建独立的临时用户"""
|
||||
demo_openid = f"demo_{uuid.uuid4().hex[:12]}"
|
||||
user = User(
|
||||
openid=demo_openid,
|
||||
name="体验用户",
|
||||
)
|
||||
await user.insert()
|
||||
|
||||
token = create_access_token(str(user.id))
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user_id=str(user.id),
|
||||
is_new_user=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/wechat-login", response_model=TokenResponse)
|
||||
async def wechat_login(req: WechatLoginRequest):
|
||||
"""微信小程序登录"""
|
||||
try:
|
||||
wechat_data = await code_to_session(req.code)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
openid = wechat_data["openid"]
|
||||
user = await User.find_one(User.openid == openid)
|
||||
is_new = user is None
|
||||
|
||||
if not user:
|
||||
user = User(
|
||||
openid=openid,
|
||||
session_key=wechat_data.get("session_key"),
|
||||
union_id=wechat_data.get("unionid"),
|
||||
)
|
||||
await user.insert()
|
||||
else:
|
||||
user.session_key = wechat_data.get("session_key")
|
||||
await user.save()
|
||||
|
||||
token = create_access_token(str(user.id))
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user_id=str(user.id),
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/phone-login", response_model=TokenResponse)
|
||||
async def phone_login(req: PhoneLoginRequest):
|
||||
"""手机号登录(简化版,生产环境需接入短信验证)"""
|
||||
# TODO: 接入真实短信验证服务
|
||||
if req.sms_code != "123456":
|
||||
raise HTTPException(status_code=400, detail="验证码错误")
|
||||
|
||||
user = await User.find_one(User.phone == req.phone)
|
||||
is_new = user is None
|
||||
|
||||
if not user:
|
||||
try:
|
||||
user = User(openid=f"phone_{req.phone}", phone=req.phone)
|
||||
await user.insert()
|
||||
except DuplicateKeyError:
|
||||
# 并发竞态:另一个请求已创建该用户,回退查找
|
||||
user = await User.find_one(User.phone == req.phone)
|
||||
is_new = False
|
||||
|
||||
token = create_access_token(str(user.id))
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user_id=str(user.id),
|
||||
is_new_user=is_new,
|
||||
)
|
||||
101
backend/app/routers/calendar.py
Normal file
101
backend/app/routers/calendar.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.checkup import CheckupPlan, CheckupItem
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 标准产检计划
|
||||
STANDARD_CHECKUPS = [
|
||||
CheckupItem(title="首次产检", week=6, items=["B超确认宫内孕", "血常规", "尿常规", "肝功能", "肾功能"], preparations=["空腹", "带身份证和医保卡"], notes="确认怀孕和预产期", importance="high"),
|
||||
CheckupItem(title="NT检查", week=12, items=["NT超声筛查", "血清学筛查", "血压", "体重"], preparations=["不需要空腹", "需憋尿"], notes="筛查唐氏综合征风险", importance="high"),
|
||||
CheckupItem(title="唐氏筛查", week=16, items=["唐氏筛查抽血", "血压", "体重", "宫高腹围"], preparations=["空腹抽血"], notes="评估胎儿染色体异常风险", importance="high"),
|
||||
CheckupItem(title="大排畸检查", week=22, items=["四维彩超", "血压", "体重", "宫高腹围"], preparations=["不需要空腹", "提前预约"], notes="全面筛查胎儿结构畸形", importance="high"),
|
||||
CheckupItem(title="糖耐量检查", week=24, items=["OGTT糖耐量试验", "血压", "体重"], preparations=["空腹8-14小时", "检查前3天正常饮食"], notes="筛查妊娠期糖尿病", importance="high"),
|
||||
CheckupItem(title="常规产检", week=28, items=["血常规", "尿常规", "血压", "体重", "胎心监护"], preparations=["空腹"], notes="进入孕晚期,增加产检频率", importance="medium"),
|
||||
CheckupItem(title="常规产检", week=30, items=["B超评估胎儿发育", "血压", "体重", "胎心监护"], preparations=[], notes="评估胎儿生长发育", importance="medium"),
|
||||
CheckupItem(title="常规产检", week=32, items=["血常规", "尿常规", "血压", "体重", "胎心监护", "B超"], preparations=["空腹"], notes="观察胎位", importance="medium"),
|
||||
CheckupItem(title="GBS筛查", week=36, items=["B族链球菌筛查", "血压", "体重", "胎心监护", "内检"], preparations=[], notes="预防新生儿感染", importance="high"),
|
||||
CheckupItem(title="每周产检", week=37, items=["胎心监护", "血压", "体重", "宫高腹围"], preparations=[], notes="每周一次,准备待产", importance="medium"),
|
||||
CheckupItem(title="每周产检", week=38, items=["胎心监护", "B超评估", "血压", "体重"], preparations=[], notes="评估分娩时机", importance="medium"),
|
||||
CheckupItem(title="每周产检", week=39, items=["胎心监护", "血压", "体重", "宫颈评估"], preparations=[], notes="关注产兆", importance="high"),
|
||||
CheckupItem(title="预产期产检", week=40, items=["胎心监护", "B超", "血压", "体重", "催产评估"], preparations=[], notes="超过预产期需密切监护", importance="high"),
|
||||
]
|
||||
|
||||
|
||||
@router.get("/plan")
|
||||
async def get_checkup_plan(user: User = Depends(get_current_user)):
|
||||
"""获取产检计划"""
|
||||
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user.id)
|
||||
|
||||
if not plan:
|
||||
# Generate default plan
|
||||
plan = CheckupPlan(user_id=user.id, checkups=STANDARD_CHECKUPS)
|
||||
await plan.insert()
|
||||
|
||||
# Mark completed checkups based on current week
|
||||
current_week = user.pregnancy_week or 0
|
||||
checkups = []
|
||||
for c in plan.checkups:
|
||||
status = c.status
|
||||
if c.week < current_week and status == "upcoming":
|
||||
status = "missed"
|
||||
checkups.append({
|
||||
**c.model_dump(),
|
||||
"status": status,
|
||||
})
|
||||
|
||||
# Find next checkup
|
||||
next_checkup = None
|
||||
for c in checkups:
|
||||
if c["status"] == "upcoming":
|
||||
next_checkup = c
|
||||
break
|
||||
|
||||
return {
|
||||
"checkups": checkups,
|
||||
"next_checkup": next_checkup,
|
||||
"current_week": current_week,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/plan/{week}/complete")
|
||||
async def complete_checkup(week: int, user: User = Depends(get_current_user)):
|
||||
"""标记产检已完成"""
|
||||
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user.id)
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="产检计划不存在")
|
||||
|
||||
for checkup in plan.checkups:
|
||||
if checkup.week == week:
|
||||
checkup.status = "completed"
|
||||
break
|
||||
|
||||
plan.updated_at = datetime.now(timezone.utc)
|
||||
await plan.save()
|
||||
return {"message": "已标记完成"}
|
||||
|
||||
|
||||
async def link_report_to_checkup(user_id, report_id: str, pregnancy_week: int):
|
||||
"""将报告自动关联到最近的产检(±2周窗口),并标记该产检为已完成"""
|
||||
plan = await CheckupPlan.find_one(CheckupPlan.user_id == user_id)
|
||||
if not plan:
|
||||
return
|
||||
|
||||
best = None
|
||||
best_diff = 999
|
||||
for c in plan.checkups:
|
||||
diff = abs(c.week - pregnancy_week)
|
||||
if diff <= 2 and diff < best_diff:
|
||||
best = c
|
||||
best_diff = diff
|
||||
|
||||
if best:
|
||||
if report_id not in best.report_ids:
|
||||
best.report_ids.append(report_id)
|
||||
best.status = "completed"
|
||||
plan.updated_at = datetime.now(timezone.utc)
|
||||
await plan.save()
|
||||
98
backend/app/routers/chat.py
Normal file
98
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.chat import ChatSession, ChatMessage
|
||||
from app.models.report import Report
|
||||
from app.models.user import User
|
||||
from app.schemas.chat import ChatMessageRequest, ChatSessionResponse, ChatMessageResponse
|
||||
from app.services.ai_service import chat_with_ai
|
||||
from app.utils.helpers import parse_object_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{report_id}/message")
|
||||
async def send_message(
|
||||
report_id: str,
|
||||
req: ChatMessageRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""发送消息并获取 AI 回复"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
|
||||
# Get or create chat session
|
||||
session = await ChatSession.find_one(
|
||||
ChatSession.user_id == user.id,
|
||||
ChatSession.report_id == report.id,
|
||||
)
|
||||
if not session:
|
||||
session = ChatSession(user_id=user.id, report_id=report.id)
|
||||
await session.insert()
|
||||
|
||||
# Add user message
|
||||
user_msg = ChatMessage(role="user", content=req.content)
|
||||
session.messages.append(user_msg)
|
||||
|
||||
# Get AI response
|
||||
history = [{"role": m.role, "content": m.content} for m in session.messages[:-1]]
|
||||
try:
|
||||
ai_response = await chat_with_ai(report, user, history, req.content)
|
||||
except Exception:
|
||||
logger.exception("AI 对话失败 report_id=%s", report_id)
|
||||
raise HTTPException(status_code=502, detail="AI 对话服务暂时不可用,请稍后重试")
|
||||
|
||||
# Add AI message
|
||||
ai_msg = ChatMessage(role="assistant", content=ai_response)
|
||||
session.messages.append(ai_msg)
|
||||
session.updated_at = datetime.now(timezone.utc)
|
||||
await session.save()
|
||||
|
||||
return {
|
||||
"user_message": {"role": "user", "content": req.content, "timestamp": user_msg.timestamp.isoformat()},
|
||||
"ai_message": {"role": "assistant", "content": ai_response, "timestamp": ai_msg.timestamp.isoformat()},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{report_id}/history")
|
||||
async def get_chat_history(
|
||||
report_id: str, user: User = Depends(get_current_user)
|
||||
):
|
||||
"""获取聊天历史"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
|
||||
session = await ChatSession.find_one(
|
||||
ChatSession.user_id == user.id,
|
||||
ChatSession.report_id == report.id,
|
||||
)
|
||||
|
||||
if not session:
|
||||
return {"messages": [], "quick_questions": _get_quick_questions()}
|
||||
|
||||
return {
|
||||
"messages": [
|
||||
{"role": m.role, "content": m.content, "timestamp": m.timestamp.isoformat()}
|
||||
for m in session.messages
|
||||
],
|
||||
"quick_questions": _get_quick_questions(),
|
||||
}
|
||||
|
||||
|
||||
def _get_quick_questions() -> list[str]:
|
||||
return [
|
||||
"这个指标偏高会影响宝宝吗?",
|
||||
"我需要注意什么饮食?",
|
||||
"什么时候需要复查?",
|
||||
"这个结果严重吗?",
|
||||
"有什么改善建议?",
|
||||
]
|
||||
96
backend/app/routers/doctor.py
Normal file
96
backend/app/routers/doctor.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.report import Report
|
||||
from app.models.user import User
|
||||
from app.services.ai_service import generate_doctor_questions
|
||||
from app.utils.helpers import parse_object_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 预设医生列表
|
||||
DOCTORS = [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "王丽华",
|
||||
"title": "主任医师",
|
||||
"hospital": "北京协和医院",
|
||||
"specialty": "高危妊娠、产前诊断",
|
||||
"experience": "25年",
|
||||
"rating": 4.9,
|
||||
"review_count": 1280,
|
||||
"consult_count": 5600,
|
||||
"response_time": "30分钟内",
|
||||
"price": {"text": 50, "video": 200, "urgent": 100},
|
||||
"tags": ["高危妊娠", "产前诊断", "遗传咨询"],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "张美琳",
|
||||
"title": "副主任医师",
|
||||
"hospital": "上海第一妇婴保健院",
|
||||
"specialty": "孕期营养、妊娠期糖尿病",
|
||||
"experience": "18年",
|
||||
"rating": 4.8,
|
||||
"review_count": 960,
|
||||
"consult_count": 3800,
|
||||
"response_time": "1小时内",
|
||||
"price": {"text": 40, "video": 180, "urgent": 80},
|
||||
"tags": ["孕期营养", "妊娠糖尿病", "体重管理"],
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "刘晓芳",
|
||||
"title": "主任医师",
|
||||
"hospital": "广州市妇女儿童医疗中心",
|
||||
"specialty": "超声诊断、胎儿医学",
|
||||
"experience": "20年",
|
||||
"rating": 4.9,
|
||||
"review_count": 1100,
|
||||
"consult_count": 4200,
|
||||
"response_time": "2小时内",
|
||||
"price": {"text": 45, "video": 190, "urgent": 90},
|
||||
"tags": ["超声诊断", "胎儿医学", "产前筛查"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/questions/{report_id}")
|
||||
async def get_doctor_questions(
|
||||
report_id: str, user: User = Depends(get_current_user)
|
||||
):
|
||||
"""生成问诊清单"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
|
||||
try:
|
||||
result = await generate_doctor_questions(report, user)
|
||||
except Exception:
|
||||
logger.exception("问诊清单生成失败 report_id=%s", report_id)
|
||||
raise HTTPException(status_code=502, detail="AI 服务暂时不可用,请稍后重试")
|
||||
|
||||
return {
|
||||
"report_id": report_id,
|
||||
"questions": result.get("questions", []),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/experts")
|
||||
async def list_experts():
|
||||
"""获取专家医生列表"""
|
||||
return {"doctors": DOCTORS}
|
||||
|
||||
|
||||
@router.get("/experts/{doctor_id}")
|
||||
async def get_expert_detail(doctor_id: str):
|
||||
"""获取医生详情"""
|
||||
for doc in DOCTORS:
|
||||
if doc["id"] == doctor_id:
|
||||
return doc
|
||||
raise HTTPException(status_code=404, detail="医生不存在")
|
||||
258
backend/app/routers/health_records.py
Normal file
258
backend/app/routers/health_records.py
Normal file
@@ -0,0 +1,258 @@
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from beanie import PydanticObjectId
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.report import Report
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_numeric_value(value) -> Optional[float]:
|
||||
value_str = str(value).strip() if value is not None else ""
|
||||
if not value_str:
|
||||
return None
|
||||
match = re.search(r'(\d+\.?\d*)', value_str)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return float(match.group(1))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_reference_range(reference_range: str) -> Tuple[Optional[float], Optional[float]]:
|
||||
if not reference_range:
|
||||
return None, None
|
||||
match = re.search(r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)', reference_range)
|
||||
if not match:
|
||||
return None, None
|
||||
return float(match.group(1)), float(match.group(2))
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_health_overview(user: User = Depends(get_current_user)):
|
||||
"""健康档案概览"""
|
||||
reports = await Report.find(Report.user_id == user.id).sort("-created_at").to_list()
|
||||
total = len(reports)
|
||||
normal = sum(1 for r in reports if r.overall_status == "normal")
|
||||
attention = sum(1 for r in reports if r.overall_status == "attention")
|
||||
warning = sum(1 for r in reports if r.overall_status == "warning")
|
||||
|
||||
recent = [
|
||||
{
|
||||
"id": str(r.id),
|
||||
"report_type": r.report_type,
|
||||
"overall_status": r.overall_status,
|
||||
"created_at": r.created_at.isoformat(),
|
||||
}
|
||||
for r in reports[:5]
|
||||
]
|
||||
|
||||
return {
|
||||
"stats": {
|
||||
"total": total,
|
||||
"normal": normal,
|
||||
"attention": attention,
|
||||
"warning": warning,
|
||||
},
|
||||
"recent_reports": recent,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/growth-curve")
|
||||
async def get_growth_curve(user: User = Depends(get_current_user)):
|
||||
"""获取胎儿生长曲线数据(从B超报告提取)"""
|
||||
reports = await Report.find(
|
||||
Report.user_id == user.id,
|
||||
Report.report_type == "B超",
|
||||
).sort("pregnancy_week").to_list()
|
||||
|
||||
data_points = []
|
||||
for r in reports:
|
||||
if not r.pregnancy_week:
|
||||
continue
|
||||
point = {"week": r.pregnancy_week}
|
||||
for item in r.data_items:
|
||||
name_lower = item.name.lower()
|
||||
numeric_value = _parse_numeric_value(item.value)
|
||||
if numeric_value is None:
|
||||
continue
|
||||
|
||||
# 匹配指标名称
|
||||
if "双顶径" in item.name or "bpd" in name_lower:
|
||||
point["bpd"] = numeric_value
|
||||
elif "腹围" in item.name or "ac" in name_lower:
|
||||
point["ac"] = numeric_value
|
||||
elif "股骨长" in item.name or "fl" in name_lower:
|
||||
point["fl"] = numeric_value
|
||||
|
||||
# 只有包含至少一个生长指标的数据点才添加
|
||||
if any(k in point for k in ["bpd", "ac", "fl"]):
|
||||
data_points.append(point)
|
||||
|
||||
return {"data_points": data_points}
|
||||
|
||||
|
||||
@router.get("/timeline")
|
||||
async def get_timeline(user: User = Depends(get_current_user)):
|
||||
"""报告时间轴"""
|
||||
reports = await Report.find(Report.user_id == user.id).sort("-created_at").to_list()
|
||||
|
||||
timeline = []
|
||||
for r in reports:
|
||||
week = r.pregnancy_week or 0
|
||||
if week <= 12:
|
||||
period = "early"
|
||||
elif week <= 28:
|
||||
period = "mid"
|
||||
else:
|
||||
period = "late"
|
||||
|
||||
timeline.append({
|
||||
"id": str(r.id),
|
||||
"report_type": r.report_type,
|
||||
"date": r.created_at.isoformat(),
|
||||
"status": r.overall_status,
|
||||
"summary": f"{len(r.data_items)}项指标",
|
||||
"period": period,
|
||||
"week": week,
|
||||
})
|
||||
|
||||
return {"reports": timeline}
|
||||
|
||||
|
||||
@router.get("/indicator-trends")
|
||||
async def get_indicator_trends(user: User = Depends(get_current_user)):
|
||||
"""获取跨报告指标趋势数据(同名数值指标出现2+次的)"""
|
||||
reports = await Report.find(
|
||||
Report.user_id == user.id,
|
||||
).sort("pregnancy_week", "created_at").to_list()
|
||||
|
||||
# 收集所有指标:indicator_name -> list of {week, value, unit, ref_range, status, date, report_type}
|
||||
indicator_map: dict[str, list[dict]] = {}
|
||||
|
||||
for r in reports:
|
||||
week = r.pregnancy_week or 0
|
||||
date_str = r.created_at.isoformat() if r.created_at else ""
|
||||
for item in r.data_items:
|
||||
value_str = str(item.value).strip() if item.value else ""
|
||||
if not value_str:
|
||||
continue
|
||||
match = re.search(r'(\d+\.?\d*)', value_str)
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
numeric_value = float(match.group(1))
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
name = item.name.strip()
|
||||
if name not in indicator_map:
|
||||
indicator_map[name] = []
|
||||
indicator_map[name].append({
|
||||
"week": week,
|
||||
"value": numeric_value,
|
||||
"unit": item.unit or "",
|
||||
"reference_range": item.reference_range or "",
|
||||
"status": item.status or "normal",
|
||||
"date": date_str,
|
||||
"report_type": r.report_type,
|
||||
})
|
||||
|
||||
# 保留所有有数据的指标(含仅1次的,用于综合分析)
|
||||
indicators = []
|
||||
for name, points in indicator_map.items():
|
||||
|
||||
# 解析参考范围(取第一个非空的)
|
||||
ref_lower = None
|
||||
ref_upper = None
|
||||
for p in points:
|
||||
if p["reference_range"]:
|
||||
# 尝试解析 "3.5-5.0" 或 "3.5~5.0" 格式
|
||||
ref_match = re.search(r'(\d+\.?\d*)\s*[-~]\s*(\d+\.?\d*)', p["reference_range"])
|
||||
if ref_match:
|
||||
ref_lower = float(ref_match.group(1))
|
||||
ref_upper = float(ref_match.group(2))
|
||||
break
|
||||
|
||||
indicators.append({
|
||||
"name": name,
|
||||
"unit": points[0]["unit"],
|
||||
"ref_lower": ref_lower,
|
||||
"ref_upper": ref_upper,
|
||||
"points": [{"week": p["week"], "value": p["value"], "date": p["date"],
|
||||
"status": p["status"], "report_type": p["report_type"]} for p in points],
|
||||
})
|
||||
|
||||
return {"indicators": indicators}
|
||||
|
||||
|
||||
@router.get("/report-type-trends")
|
||||
async def get_report_type_trends(user: User = Depends(get_current_user)):
|
||||
"""按报告类型分组的指标趋势(同类型报告中出现2+次的数值指标)"""
|
||||
reports = await Report.find(
|
||||
Report.user_id == user.id,
|
||||
).sort("pregnancy_week", "created_at").to_list()
|
||||
|
||||
# report_type -> indicator_name -> list of data points
|
||||
type_map: dict[str, dict[str, list[dict]]] = {}
|
||||
|
||||
for r in reports:
|
||||
rt = r.report_type or "其他"
|
||||
week = r.pregnancy_week or 0
|
||||
date_str = r.created_at.isoformat() if r.created_at else ""
|
||||
|
||||
if rt not in type_map:
|
||||
type_map[rt] = {}
|
||||
|
||||
for item in r.data_items:
|
||||
numeric_value = _parse_numeric_value(item.value)
|
||||
if numeric_value is None:
|
||||
continue
|
||||
|
||||
name = item.name.strip()
|
||||
if name not in type_map[rt]:
|
||||
type_map[rt][name] = []
|
||||
type_map[rt][name].append({
|
||||
"week": week,
|
||||
"value": numeric_value,
|
||||
"unit": item.unit or "",
|
||||
"reference_range": item.reference_range or "",
|
||||
"status": item.status or "normal",
|
||||
"date": date_str,
|
||||
})
|
||||
|
||||
# 组装结果:保留所有有数据的指标(含仅1个数据点的)
|
||||
result = []
|
||||
for rt, ind_map in type_map.items():
|
||||
indicators = []
|
||||
for name, points in ind_map.items():
|
||||
ref_lower, ref_upper = None, None
|
||||
for p in points:
|
||||
ref_lower, ref_upper = _parse_reference_range(p["reference_range"])
|
||||
if ref_lower is not None:
|
||||
break
|
||||
indicators.append({
|
||||
"name": name,
|
||||
"unit": points[0]["unit"],
|
||||
"ref_lower": ref_lower,
|
||||
"ref_upper": ref_upper,
|
||||
"points": [{"week": p["week"], "value": p["value"], "date": p["date"],
|
||||
"status": p["status"]} for p in points],
|
||||
})
|
||||
if indicators:
|
||||
# 按数据点数量降序,让最有数据的指标排前面
|
||||
indicators.sort(key=lambda x: len(x["points"]), reverse=True)
|
||||
result.append({
|
||||
"report_type": rt,
|
||||
"report_count": sum(1 for r in reports if (r.report_type or "其他") == rt),
|
||||
"indicators": indicators,
|
||||
})
|
||||
|
||||
# 按报告份数降序
|
||||
result.sort(key=lambda x: x["report_count"], reverse=True)
|
||||
return {"report_types": result}
|
||||
290
backend/app/routers/report.py
Normal file
290
backend/app/routers/report.py
Normal file
@@ -0,0 +1,290 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.report import Report, DataItem
|
||||
from app.models.user import User
|
||||
from app.schemas.report import (
|
||||
ReportVerifyRequest,
|
||||
ReportResponse,
|
||||
ReportListItem,
|
||||
DataItemSchema,
|
||||
)
|
||||
from app.services.storage import save_upload_file
|
||||
from app.services.ocr import ocr_recognize
|
||||
from app.utils.helpers import parse_object_id, mask_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_report_image(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""上传报告图片并进行 OCR 识别"""
|
||||
# Validate file type
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的文件类型: {file.content_type},请上传 JPG/PNG/WebP 图片",
|
||||
)
|
||||
|
||||
# Validate file size (read content and check)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
|
||||
# Reset file position for storage
|
||||
await file.seek(0)
|
||||
|
||||
url = await save_upload_file(file, sub_dir="reports")
|
||||
|
||||
# OCR recognition
|
||||
try:
|
||||
ocr_result = await ocr_recognize(url)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="文件保存异常,请重试")
|
||||
except RuntimeError as e:
|
||||
# OCR 服务已捕获并转换为友好消息
|
||||
logger.warning("OCR 识别失败: %s", e)
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("OCR 识别失败")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="AI识别服务暂时不可用,请稍后重试",
|
||||
)
|
||||
|
||||
# 检查是否为孕期相关报告
|
||||
if not ocr_result.get("is_pregnancy_report", True):
|
||||
reject_reason = ocr_result.get("reject_reason", "")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"请上传孕期检查报告。{reject_reason}",
|
||||
)
|
||||
|
||||
# 根据报告日期 + 末次经期推算检查时的孕周
|
||||
report_date_str = ocr_result.get("report_date")
|
||||
pregnancy_week_at_report = user.pregnancy_week # 默认用用户当前孕周
|
||||
if report_date_str and user.last_period_date:
|
||||
try:
|
||||
report_dt = date.fromisoformat(report_date_str)
|
||||
lmp = date.fromisoformat(user.last_period_date) if isinstance(user.last_period_date, str) else user.last_period_date
|
||||
delta_days = (report_dt - lmp).days
|
||||
if 0 < delta_days < 300:
|
||||
pregnancy_week_at_report = delta_days // 7
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Create report with OCR data
|
||||
data_items = [DataItem(**item) for item in ocr_result.get("data_items", [])]
|
||||
report = Report(
|
||||
user_id=user.id,
|
||||
report_type=ocr_result.get("report_type", ""),
|
||||
pregnancy_week=pregnancy_week_at_report,
|
||||
report_date=report_date_str,
|
||||
image_urls=[url],
|
||||
data_items=data_items,
|
||||
patient_name=ocr_result.get("patient_name"),
|
||||
status="pending",
|
||||
)
|
||||
await report.insert()
|
||||
|
||||
return {
|
||||
"report_id": str(report.id),
|
||||
"image_url": url,
|
||||
"ocr_result": {
|
||||
"report_type": report.report_type,
|
||||
"data_items": [item.model_dump() for item in data_items],
|
||||
"patient_name": mask_name(report.patient_name),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{report_id}/add-image")
|
||||
async def add_image_to_report(
|
||||
report_id: str,
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""向已有报告追加图片并进行 OCR 识别,合并数据"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的文件类型: {file.content_type},请上传 JPG/PNG/WebP 图片",
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
|
||||
await file.seek(0)
|
||||
|
||||
url = await save_upload_file(file, sub_dir="reports")
|
||||
|
||||
# OCR recognition
|
||||
try:
|
||||
ocr_result = await ocr_recognize(url)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="文件保存异常,请重试")
|
||||
except RuntimeError as e:
|
||||
logger.warning("OCR 识别失败: %s", e)
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
except Exception:
|
||||
logger.exception("OCR 识别失败")
|
||||
raise HTTPException(status_code=500, detail="AI识别服务暂时不可用,请稍后重试")
|
||||
|
||||
# 检查是否为孕期相关报告
|
||||
if not ocr_result.get("is_pregnancy_report", True):
|
||||
reject_reason = ocr_result.get("reject_reason", "")
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"请上传孕期检查报告。{reject_reason}",
|
||||
)
|
||||
|
||||
# Merge new data into existing report
|
||||
new_items = [DataItem(**item) for item in ocr_result.get("data_items", [])]
|
||||
report.image_urls.append(url)
|
||||
report.data_items.extend(new_items)
|
||||
|
||||
# Update report_type if current report has multiple types
|
||||
new_type = ocr_result.get("report_type", "")
|
||||
if new_type and new_type != report.report_type:
|
||||
report.report_type = f"{report.report_type}+{new_type}" if report.report_type else new_type
|
||||
|
||||
report.updated_at = datetime.now(timezone.utc)
|
||||
await report.save()
|
||||
|
||||
return {
|
||||
"report_id": str(report.id),
|
||||
"image_url": url,
|
||||
"image_count": len(report.image_urls),
|
||||
"ocr_result": {
|
||||
"report_type": new_type,
|
||||
"data_items": [item.model_dump() for item in new_items],
|
||||
"patient_name": mask_name(ocr_result.get("patient_name")),
|
||||
},
|
||||
"total_items": len(report.data_items),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{report_id}/verify", response_model=ReportResponse)
|
||||
async def verify_report(
|
||||
report_id: str,
|
||||
req: ReportVerifyRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""数据校对确认"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
|
||||
report.data_items = [DataItem(**item.model_dump()) for item in req.data_items]
|
||||
report.symptoms = req.symptoms
|
||||
report.notes = req.notes
|
||||
if req.report_type:
|
||||
report.report_type = req.report_type
|
||||
report.status = "verified"
|
||||
|
||||
# Calculate overall status
|
||||
statuses = [item.status for item in report.data_items]
|
||||
if any(s in ("high", "low") for s in statuses):
|
||||
abnormal_count = sum(1 for s in statuses if s != "normal")
|
||||
report.overall_status = "warning" if abnormal_count >= 3 else "attention"
|
||||
else:
|
||||
report.overall_status = "normal"
|
||||
|
||||
report.updated_at = datetime.now(timezone.utc)
|
||||
await report.save()
|
||||
|
||||
return _report_to_response(report)
|
||||
|
||||
|
||||
@router.get("/list", response_model=list[ReportListItem])
|
||||
async def list_reports(
|
||||
limit: int = 50,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取报告列表"""
|
||||
reports = await Report.find(
|
||||
Report.user_id == user.id
|
||||
).sort("-created_at").limit(limit).to_list()
|
||||
|
||||
return [
|
||||
ReportListItem(
|
||||
id=str(r.id),
|
||||
report_type=r.report_type,
|
||||
pregnancy_week=r.pregnancy_week,
|
||||
overall_status=r.overall_status,
|
||||
summary=_generate_summary(r),
|
||||
item_count=len(r.data_items),
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in reports
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_model=ReportResponse)
|
||||
async def get_report(report_id: str, user: User = Depends(get_current_user)):
|
||||
"""获取报告详情"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
return _report_to_response(report)
|
||||
|
||||
|
||||
def _generate_summary(report: Report) -> str:
|
||||
"""从报告指标中生成有意义的摘要文本"""
|
||||
if not report.data_items:
|
||||
return "暂无指标数据"
|
||||
|
||||
# 优先展示异常指标
|
||||
abnormal = [
|
||||
item for item in report.data_items if item.status in ("high", "low")
|
||||
]
|
||||
# 再补充正常的关键指标
|
||||
normal = [item for item in report.data_items if item.status == "normal"]
|
||||
|
||||
parts = []
|
||||
# 先添加异常指标(最多2个)
|
||||
for item in abnormal[:2]:
|
||||
flag = "↑偏高" if item.status == "high" else "↓偏低"
|
||||
val = f"{item.name}{item.value}{item.unit}({flag})" if item.unit else f"{item.name}{item.value}({flag})"
|
||||
parts.append(val)
|
||||
# 再补充正常指标,总共最多3个
|
||||
for item in normal[:3 - len(parts)]:
|
||||
val = f"{item.name}{item.value}{item.unit}" if item.unit else f"{item.name}{item.value}"
|
||||
parts.append(val)
|
||||
|
||||
if not parts:
|
||||
return f"共{len(report.data_items)}项指标"
|
||||
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def _report_to_response(report: Report) -> ReportResponse:
|
||||
return ReportResponse(
|
||||
id=str(report.id),
|
||||
report_type=report.report_type,
|
||||
pregnancy_week=report.pregnancy_week,
|
||||
image_urls=report.image_urls,
|
||||
data_items=[DataItemSchema(**item.model_dump()) for item in report.data_items],
|
||||
symptoms=report.symptoms,
|
||||
notes=report.notes,
|
||||
status=report.status,
|
||||
overall_status=report.overall_status,
|
||||
created_at=report.created_at,
|
||||
)
|
||||
70
backend/app/routers/share.py
Normal file
70
backend/app/routers/share.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.analysis import Analysis
|
||||
from app.models.report import Report
|
||||
from app.models.user import User
|
||||
from app.utils.helpers import parse_object_id, mask_name
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ShareOptions(BaseModel):
|
||||
privacy_mode: bool = True # 隐私模式,脱敏个人信息
|
||||
include_details: bool = False # 是否包含详细数据
|
||||
|
||||
|
||||
@router.post("/{report_id}/generate")
|
||||
async def generate_share_data(
|
||||
report_id: str,
|
||||
options: ShareOptions = ShareOptions(privacy_mode=True, include_details=True),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""生成分享数据"""
|
||||
oid = parse_object_id(report_id)
|
||||
report = await Report.get(oid)
|
||||
if not report or report.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
|
||||
analysis = await Analysis.find_one(Analysis.report_id == report.id)
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail="暂无解读结果")
|
||||
|
||||
# Build share data
|
||||
share_data = {
|
||||
"report_type": report.report_type,
|
||||
"pregnancy_week": report.pregnancy_week,
|
||||
"overall_status": analysis.overall_status,
|
||||
"risk_level": analysis.risk_level,
|
||||
"overall_summary": analysis.overall_summary,
|
||||
"emotional_summary": analysis.emotional_summary,
|
||||
"action_items": [a.model_dump() for a in analysis.action_items],
|
||||
"tips": analysis.tips,
|
||||
"categories": [
|
||||
{
|
||||
"category": cat.category,
|
||||
"status": cat.status,
|
||||
"indicators": [
|
||||
{
|
||||
"name": ind.name,
|
||||
"nickname": ind.nickname,
|
||||
"value": ind.value,
|
||||
"status": ind.status,
|
||||
"what_is_it": ind.what_is_it,
|
||||
"impact": ind.impact,
|
||||
"suggestion": ind.suggestion,
|
||||
}
|
||||
for ind in cat.indicators
|
||||
],
|
||||
}
|
||||
for cat in analysis.categories
|
||||
],
|
||||
}
|
||||
|
||||
if not options.privacy_mode:
|
||||
share_data["user_name"] = user.name
|
||||
else:
|
||||
share_data["user_name"] = mask_name(user.name)
|
||||
|
||||
return {"share_data": share_data}
|
||||
84
backend/app/routers/user.py
Normal file
84
backend/app/routers/user.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.user import (
|
||||
OnboardingRequest,
|
||||
UserProfileUpdate,
|
||||
UserProfileResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_to_response(user: User) -> UserProfileResponse:
|
||||
return UserProfileResponse(
|
||||
id=str(user.id),
|
||||
name=user.name,
|
||||
phone=user.phone,
|
||||
age=user.age,
|
||||
height=user.height,
|
||||
pre_pregnancy_weight=user.pre_pregnancy_weight,
|
||||
current_weight=user.current_weight,
|
||||
blood_type=user.blood_type,
|
||||
allergies=user.allergies,
|
||||
medical_history=user.medical_history,
|
||||
last_period_date=user.last_period_date,
|
||||
due_date=user.due_date,
|
||||
pregnancy_week=user.pregnancy_week,
|
||||
pregnancy_day=user.pregnancy_day,
|
||||
multiple_pregnancy=user.multiple_pregnancy,
|
||||
pregnancy_count=user.pregnancy_count,
|
||||
emergency_contact=user.emergency_contact.model_dump() if user.emergency_contact else None,
|
||||
is_onboarded=user.is_onboarded,
|
||||
avatar_url=user.avatar_url,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profile", response_model=UserProfileResponse)
|
||||
async def get_profile(user: User = Depends(get_current_user)):
|
||||
"""获取用户信息"""
|
||||
return _user_to_response(user)
|
||||
|
||||
|
||||
@router.post("/onboarding", response_model=UserProfileResponse)
|
||||
async def complete_onboarding(
|
||||
req: OnboardingRequest, user: User = Depends(get_current_user)
|
||||
):
|
||||
"""完成用户画像设置"""
|
||||
user.name = req.name or user.name
|
||||
user.age = req.age
|
||||
user.height = req.height
|
||||
user.pre_pregnancy_weight = req.pre_pregnancy_weight
|
||||
user.current_weight = req.current_weight
|
||||
user.blood_type = req.blood_type
|
||||
user.allergies = req.allergies
|
||||
user.medical_history = req.medical_history
|
||||
user.last_period_date = req.last_period_date
|
||||
user.due_date = req.due_date
|
||||
user.pregnancy_week = req.pregnancy_week
|
||||
user.pregnancy_day = req.pregnancy_day
|
||||
user.multiple_pregnancy = req.multiple_pregnancy
|
||||
user.pregnancy_count = req.pregnancy_count
|
||||
if req.emergency_contact:
|
||||
user.emergency_contact = req.emergency_contact
|
||||
user.is_onboarded = True
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
await user.save()
|
||||
return _user_to_response(user)
|
||||
|
||||
|
||||
@router.put("/profile", response_model=UserProfileResponse)
|
||||
async def update_profile(
|
||||
req: UserProfileUpdate, user: User = Depends(get_current_user)
|
||||
):
|
||||
"""更新用户信息"""
|
||||
update_data = req.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
await user.save()
|
||||
return _user_to_response(user)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
20
backend/app/schemas/auth.py
Normal file
20
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class WechatLoginRequest(BaseModel):
|
||||
code: str # wx.login() 获取的 code
|
||||
|
||||
|
||||
class PhoneLoginRequest(BaseModel):
|
||||
phone: str
|
||||
sms_code: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user_id: str
|
||||
is_new_user: bool = False
|
||||
21
backend/app/schemas/chat.py
Normal file
21
backend/app/schemas/chat.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ChatMessageRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class ChatSessionResponse(BaseModel):
|
||||
id: str
|
||||
report_id: str
|
||||
messages: list[ChatMessageResponse]
|
||||
created_at: datetime
|
||||
44
backend/app/schemas/report.py
Normal file
44
backend/app/schemas/report.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DataItemSchema(BaseModel):
|
||||
name: str
|
||||
value: str
|
||||
unit: str = ""
|
||||
reference_range: str = ""
|
||||
status: str = "normal"
|
||||
category: str = ""
|
||||
|
||||
|
||||
class ReportVerifyRequest(BaseModel):
|
||||
"""数据校对提交"""
|
||||
data_items: list[DataItemSchema]
|
||||
symptoms: Optional[list[str]] = None
|
||||
notes: Optional[str] = None
|
||||
report_type: Optional[str] = None
|
||||
|
||||
|
||||
class ReportResponse(BaseModel):
|
||||
id: str
|
||||
report_type: str
|
||||
pregnancy_week: Optional[int]
|
||||
image_urls: list[str]
|
||||
data_items: list[DataItemSchema]
|
||||
symptoms: Optional[list[str]]
|
||||
notes: Optional[str]
|
||||
status: str
|
||||
overall_status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReportListItem(BaseModel):
|
||||
id: str
|
||||
report_type: str
|
||||
pregnancy_week: Optional[int]
|
||||
overall_status: str
|
||||
summary: str = ""
|
||||
item_count: int = 0
|
||||
created_at: datetime
|
||||
70
backend/app/schemas/user.py
Normal file
70
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EmergencyContactSchema(BaseModel):
|
||||
name: str = ""
|
||||
relation: str = ""
|
||||
phone: str = ""
|
||||
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
age: Optional[int] = None
|
||||
phone: Optional[str] = None
|
||||
height: Optional[float] = None
|
||||
pre_pregnancy_weight: Optional[float] = None
|
||||
current_weight: Optional[float] = None
|
||||
blood_type: Optional[str] = None
|
||||
allergies: Optional[list[str]] = None
|
||||
medical_history: Optional[list[str]] = None
|
||||
last_period_date: Optional[datetime] = None
|
||||
due_date: Optional[datetime] = None
|
||||
pregnancy_week: Optional[int] = None
|
||||
pregnancy_day: Optional[int] = None
|
||||
multiple_pregnancy: Optional[str] = None
|
||||
pregnancy_count: Optional[int] = None
|
||||
emergency_contact: Optional[EmergencyContactSchema] = None
|
||||
|
||||
|
||||
class OnboardingRequest(BaseModel):
|
||||
name: Optional[str] = ""
|
||||
age: Optional[int] = None
|
||||
height: Optional[float] = None
|
||||
pre_pregnancy_weight: Optional[float] = None
|
||||
current_weight: Optional[float] = None
|
||||
blood_type: Optional[str] = None
|
||||
allergies: list[str] = []
|
||||
medical_history: list[str] = []
|
||||
last_period_date: Optional[datetime] = None
|
||||
due_date: Optional[datetime] = None
|
||||
pregnancy_week: Optional[int] = None
|
||||
pregnancy_day: Optional[int] = None
|
||||
multiple_pregnancy: Optional[str] = None
|
||||
pregnancy_count: Optional[int] = None
|
||||
emergency_contact: Optional[EmergencyContactSchema] = None
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
phone: Optional[str]
|
||||
age: Optional[int]
|
||||
height: Optional[float]
|
||||
pre_pregnancy_weight: Optional[float]
|
||||
current_weight: Optional[float]
|
||||
blood_type: Optional[str]
|
||||
allergies: list[str]
|
||||
medical_history: list[str]
|
||||
last_period_date: Optional[datetime]
|
||||
due_date: Optional[datetime]
|
||||
pregnancy_week: Optional[int]
|
||||
pregnancy_day: Optional[int]
|
||||
multiple_pregnancy: Optional[str]
|
||||
pregnancy_count: Optional[int]
|
||||
emergency_contact: Optional[EmergencyContactSchema]
|
||||
is_onboarded: bool
|
||||
avatar_url: Optional[str]
|
||||
created_at: datetime
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
239
backend/app/services/ai_service.py
Normal file
239
backend/app/services/ai_service.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
AI service: Report analysis, chat, doctor questions.
|
||||
Uses OpenAI-compatible API (e.g. SiliconFlow).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import settings
|
||||
from app.models.report import Report
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_client() -> AsyncOpenAI:
|
||||
return AsyncOpenAI(api_key=settings.ai_api_key, base_url=settings.ai_base_url)
|
||||
|
||||
|
||||
def _build_report_context(report: Report, user: User) -> str:
|
||||
"""构建报告上下文给 AI"""
|
||||
lines = [
|
||||
f"孕妈信息: 年龄{user.age}岁",
|
||||
]
|
||||
if report.pregnancy_week:
|
||||
lines.append(f"检查时孕周: 第{report.pregnancy_week}周")
|
||||
if report.report_date:
|
||||
lines.append(f"报告日期: {report.report_date}")
|
||||
if user.last_period_date:
|
||||
lines.append(f"末次月经: {user.last_period_date}")
|
||||
if user.height:
|
||||
lines.append(f"身高: {user.height}cm")
|
||||
if user.current_weight:
|
||||
lines.append(f"当前体重: {user.current_weight}kg")
|
||||
if user.blood_type:
|
||||
lines.append(f"血型: {user.blood_type}")
|
||||
if user.medical_history:
|
||||
lines.append(f"病史: {', '.join(user.medical_history)}")
|
||||
|
||||
lines.append(f"\n报告类型: {report.report_type}")
|
||||
lines.append("\n检查指标:")
|
||||
for item in report.data_items:
|
||||
ref = f", 参考范围: {item.reference_range}" if item.reference_range else ""
|
||||
lines.append(f" - {item.name}: {item.value} {item.unit}{ref}")
|
||||
|
||||
if report.symptoms:
|
||||
lines.append(f"\n自觉症状: {', '.join(report.symptoms)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_json_response(text: str) -> dict:
|
||||
"""从 AI 回复中提取 JSON"""
|
||||
if "```json" in text:
|
||||
text = text.split("```json")[1].split("```")[0]
|
||||
elif "```" in text:
|
||||
text = text.split("```")[1].split("```")[0]
|
||||
return json.loads(text.strip())
|
||||
|
||||
|
||||
async def _chat_complete(system: str, user_content: str, messages: Optional[list] = None, max_tokens: int = 4096) -> str:
|
||||
"""统一调用 AI 文本模型"""
|
||||
client = _get_client()
|
||||
msgs = [{"role": "system", "content": system}]
|
||||
if messages:
|
||||
msgs.extend(messages)
|
||||
else:
|
||||
msgs.append({"role": "user", "content": user_content})
|
||||
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.ai_text_model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.7, # 降低温度提高生成速度
|
||||
messages=msgs,
|
||||
)
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
logger.error("AI 调用失败: %s: %s", type(e).__name__, err_msg[:200])
|
||||
if "auth" in err_msg.lower() or "api key" in err_msg.lower():
|
||||
raise RuntimeError("AI 服务认证失败,请检查 API Key") from e
|
||||
raise RuntimeError("AI 服务暂时不可用,请稍后重试") from e
|
||||
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
# ─── Report Analysis ───
|
||||
|
||||
ANALYSIS_SYSTEM_PROMPT = """你是一位专业、温暖的孕期健康顾问AI。你的任务是解读孕期检查报告,用通俗易懂的语言帮助孕妈理解检查结果。
|
||||
|
||||
核心要求:
|
||||
1. 必须结合孕周来判断指标是否正常(孕期不同阶段的正常范围不同,如血红蛋白在孕中晚期会生理性下降)
|
||||
2. 将每个指标的测定结果与报告中的参考范围对比,准确判断偏高/偏低/正常
|
||||
3. 如果参考范围缺失,根据你的医学知识和该孕周的标准范围来判断
|
||||
4. 用温暖、关怀的语气,避免医学术语恐吓
|
||||
5. 对每个指标给出白话化的昵称(如血红蛋白→"氧气快递员",双顶径→"宝宝头围",腹围→"宝宝肚围",股骨长→"宝宝腿长")
|
||||
6. 解释每个指标的含义、对宝宝的影响、行动建议
|
||||
7. 给出整体评估和风险等级
|
||||
8. 行动建议按优先级排序
|
||||
|
||||
**特别注意:**
|
||||
- 如果是B超报告,重点关注胎儿生长指标(双顶径、腹围、股骨长),即使只有这3个指标也要给出完整的解读
|
||||
- B超报告的解读要重点说明宝宝的生长发育情况,是否符合孕周标准
|
||||
- 即使指标数量很少(如只有3个),也要给出详细的解读和建议
|
||||
|
||||
请严格按以下JSON格式返回:
|
||||
{
|
||||
"overall_status": "normal/attention/warning",
|
||||
"risk_level": "low/medium/high",
|
||||
"overall_summary": "整体评估文案",
|
||||
"emotional_summary": "情感化总结,如'宝宝发育良好,继续保持'",
|
||||
"categories": [
|
||||
{
|
||||
"category": "分类名称",
|
||||
"status": "normal/attention/warning",
|
||||
"indicators": [
|
||||
{
|
||||
"name": "指标名",
|
||||
"nickname": "白话昵称",
|
||||
"value": "值",
|
||||
"status": "normal/high/low",
|
||||
"what_is_it": "通俗解释",
|
||||
"impact": "对宝宝的影响",
|
||||
"suggestion": "行动建议",
|
||||
"recheck_reminder": "复查提醒(可选)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"action_items": [
|
||||
{
|
||||
"content": "具体建议",
|
||||
"priority": "high/medium/low",
|
||||
"category": "饮食/运动/就医/复查"
|
||||
}
|
||||
],
|
||||
"tips": ["温馨提示1", "温馨提示2"]
|
||||
}"""
|
||||
|
||||
|
||||
async def analyze_report(report: Report, user: User) -> dict:
|
||||
"""调用 AI 解读报告"""
|
||||
logger.info("开始AI解读报告 report_id=%s", report.id)
|
||||
context = _build_report_context(report, user)
|
||||
response_text = await _chat_complete(
|
||||
system=ANALYSIS_SYSTEM_PROMPT,
|
||||
user_content=f"请解读以下孕期检查报告:\n\n{context}",
|
||||
)
|
||||
try:
|
||||
return _parse_json_response(response_text)
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return {
|
||||
"overall_status": "normal",
|
||||
"risk_level": "low",
|
||||
"overall_summary": response_text,
|
||||
"emotional_summary": "报告解读完成",
|
||||
"categories": [],
|
||||
"action_items": [],
|
||||
"tips": [],
|
||||
}
|
||||
|
||||
|
||||
# ─── Chat ───
|
||||
|
||||
CHAT_SYSTEM_PROMPT = """你是一位专业、温暖的孕期健康顾问AI。孕妈正在就她的检查报告向你提问。
|
||||
|
||||
要求:
|
||||
1. 用通俗易懂、温暖关怀的语气回答
|
||||
2. 基于报告数据给出专业建议
|
||||
3. 遇到严重问题建议及时就医
|
||||
4. 回答简洁明了,避免过长
|
||||
|
||||
以下是孕妈的检查报告信息:
|
||||
{report_context}"""
|
||||
|
||||
|
||||
async def chat_with_ai(
|
||||
report: Report, user: User, messages: list[dict], user_message: str
|
||||
) -> str:
|
||||
"""AI 对话追问"""
|
||||
context = _build_report_context(report, user)
|
||||
system = CHAT_SYSTEM_PROMPT.format(report_context=context)
|
||||
|
||||
conversation = [{"role": "system", "content": system}]
|
||||
for msg in messages[-10:]:
|
||||
conversation.append({"role": msg["role"], "content": msg["content"]})
|
||||
conversation.append({"role": "user", "content": user_message})
|
||||
|
||||
client = _get_client()
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.ai_text_model,
|
||||
max_tokens=1024,
|
||||
messages=conversation,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("AI 对话失败: %s", e)
|
||||
raise RuntimeError("AI 对话服务暂时不可用") from e
|
||||
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
# ─── Doctor Questions ───
|
||||
|
||||
DOCTOR_QUESTIONS_SYSTEM_PROMPT = """你是一位孕期健康顾问AI。根据检查报告中的异常项,帮孕妈生成3-5条看医生时可以问的问题。
|
||||
|
||||
要求:
|
||||
1. 问题要专业但容易理解
|
||||
2. 按重要性排序
|
||||
3. 给出为什么要问这个问题的原因
|
||||
|
||||
请严格按以下JSON格式返回:
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"category": "分类",
|
||||
"question": "问医生的话术",
|
||||
"reason": "为什么要问这个",
|
||||
"priority": "high/medium/low"
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
|
||||
async def generate_doctor_questions(report: Report, user: User) -> dict:
|
||||
"""生成问诊清单"""
|
||||
context = _build_report_context(report, user)
|
||||
response_text = await _chat_complete(
|
||||
system=DOCTOR_QUESTIONS_SYSTEM_PROMPT,
|
||||
user_content=f"请根据以下报告生成问诊清单:\n\n{context}",
|
||||
max_tokens=1024,
|
||||
)
|
||||
try:
|
||||
return _parse_json_response(response_text)
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return {"questions": []}
|
||||
142
backend/app/services/ocr.py
Normal file
142
backend/app/services/ocr.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
OCR service: Use vision model to extract medical report data from images.
|
||||
Supports OpenAI-compatible APIs (e.g. SiliconFlow).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OCR_PROMPT = """请分析这张图片,首先判断它是否是孕期/产检相关的医疗检查报告。
|
||||
|
||||
**孕期相关报告包括但不限于:**
|
||||
- 产检报告(B超/彩超、NT检查、四维彩超、大排畸等)
|
||||
- 血液检查(血常规、血糖、血型、唐氏筛查、TORCH、甲功等)
|
||||
- 尿液检查(尿常规等)
|
||||
- 肝肾功能检查
|
||||
- 凝血功能检查
|
||||
- 心电图报告
|
||||
- 白带常规
|
||||
- 胎心监护报告
|
||||
- 其他与孕期产检相关的检查报告
|
||||
|
||||
**不属于孕期报告的情况:**
|
||||
- 非医疗报告(发票、收据、菜单、截图、照片等)
|
||||
- 与孕期无关的医疗报告(如骨科、眼科、口腔科等非产检相关)
|
||||
- 无法识别内容的模糊图片
|
||||
|
||||
**请严格按以下JSON格式返回(不要输出其他内容):**
|
||||
|
||||
如果**不是**孕期相关报告,返回:
|
||||
{
|
||||
"is_pregnancy_report": false,
|
||||
"reject_reason": "简短说明为什么不是孕期报告(如:这是一张超市购物小票)"
|
||||
}
|
||||
|
||||
如果**是**孕期相关报告,提取所有检查指标并返回:
|
||||
{
|
||||
"is_pregnancy_report": true,
|
||||
"patient_name": "患者姓名(如有)",
|
||||
"report_type": "报告类型(如血常规、尿常规、B超等)",
|
||||
"report_date": "报告日期(如有,格式 YYYY-MM-DD)",
|
||||
"data_items": [
|
||||
{
|
||||
"name": "测定项目",
|
||||
"value": "测定结果(只提取数字,不要单位)",
|
||||
"unit": "单位",
|
||||
"reference_range": "参考范围(如有)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**重要提示:**
|
||||
1. 必须先判断是否为孕期报告,is_pregnancy_report 字段必填
|
||||
2. value字段只填写纯数字,不要包含单位(如"23.5"而不是"23.5mm")
|
||||
3. 单位单独放在unit字段中
|
||||
4. 如果是B超报告,务必确保双顶径(BPD)、腹围(AC)、股骨长(FL)这三个指标被正确提取"""
|
||||
|
||||
|
||||
def _get_client() -> AsyncOpenAI:
|
||||
return AsyncOpenAI(api_key=settings.ai_api_key, base_url=settings.ai_base_url)
|
||||
|
||||
|
||||
async def ocr_recognize(image_path: str) -> dict:
|
||||
"""
|
||||
Use vision model to extract medical report data from an image.
|
||||
Returns structured OCR results.
|
||||
"""
|
||||
# Resolve path relative to upload_dir
|
||||
if image_path.startswith("/uploads/"):
|
||||
relative = image_path[len("/uploads/"):]
|
||||
full_path = Path(settings.upload_dir) / relative
|
||||
else:
|
||||
full_path = Path(settings.upload_dir) / image_path.lstrip("/")
|
||||
|
||||
if not full_path.exists():
|
||||
logger.error("OCR 文件不存在: %s", full_path)
|
||||
raise FileNotFoundError(f"报告图片不存在: {image_path}")
|
||||
|
||||
# Async file read
|
||||
async with aiofiles.open(full_path, "rb") as f:
|
||||
raw = await f.read()
|
||||
image_data = base64.standard_b64encode(raw).decode("utf-8")
|
||||
|
||||
# Determine media type
|
||||
suffix = full_path.suffix.lower()
|
||||
media_type_map = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
media_type = media_type_map.get(suffix, "image/jpeg")
|
||||
|
||||
client = _get_client()
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.ai_vision_model,
|
||||
max_tokens=2048,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:{media_type};base64,{image_data}",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": OCR_PROMPT,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
logger.error("AI OCR 调用失败: %s: %s", type(e).__name__, err_msg[:200])
|
||||
if "auth" in err_msg.lower() or "api key" in err_msg.lower():
|
||||
raise RuntimeError("AI 服务认证失败,请检查 API Key 是否有效") from e
|
||||
if "<html" in err_msg.lower() or "cloudflare" in err_msg.lower():
|
||||
raise RuntimeError("AI 服务请求被拦截,请检查 API 配置") from e
|
||||
raise RuntimeError("AI 识别服务暂时不可用,请稍后重试") from e
|
||||
|
||||
response_text = response.choices[0].message.content
|
||||
try:
|
||||
if "```json" in response_text:
|
||||
response_text = response_text.split("```json")[1].split("```")[0]
|
||||
elif "```" in response_text:
|
||||
response_text = response_text.split("```")[1].split("```")[0]
|
||||
return json.loads(response_text.strip())
|
||||
except json.JSONDecodeError:
|
||||
return {"raw_text": response_text, "data_items": []}
|
||||
24
backend/app/services/storage.py
Normal file
24
backend/app/services/storage.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
async def save_upload_file(file: UploadFile, sub_dir: str = "reports") -> str:
|
||||
"""Save uploaded file and return the relative URL path"""
|
||||
upload_path = Path(settings.upload_dir) / sub_dir
|
||||
upload_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ext = Path(file.filename).suffix if file.filename else ".jpg"
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
file_path = upload_path / filename
|
||||
|
||||
content = await file.read()
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
await f.write(content)
|
||||
|
||||
return f"/uploads/{sub_dir}/{filename}"
|
||||
26
backend/app/services/wechat.py
Normal file
26
backend/app/services/wechat.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
async def code_to_session(code: str) -> dict:
|
||||
"""用小程序 code 换取 openid 和 session_key"""
|
||||
url = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
params = {
|
||||
"appid": settings.wechat_app_id,
|
||||
"secret": settings.wechat_app_secret,
|
||||
"js_code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, params=params)
|
||||
data = resp.json()
|
||||
|
||||
if "errcode" in data and data["errcode"] != 0:
|
||||
raise Exception(f"微信登录失败: {data.get('errmsg', '未知错误')}")
|
||||
|
||||
return {
|
||||
"openid": data["openid"],
|
||||
"session_key": data["session_key"],
|
||||
"unionid": data.get("unionid"),
|
||||
}
|
||||
617
backend/app/static/index.html
Normal file
617
backend/app/static/index.html
Normal file
@@ -0,0 +1,617 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>孕育无忧 - 孕期报告智能解读</title>
|
||||
<style>
|
||||
:root{--pink:#e91e8c;--pink-light:#fce4f3;--pink-bg:#fff5fb;--green:#27ae60;--orange:#f39c12;--red:#e74c3c;--gray:#999;--border:#eee;--shadow:0 2px 12px rgba(0,0,0,.08);--radius:12px}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB",sans-serif;background:#f5f5f5;color:#333;max-width:480px;margin:0 auto;min-height:100vh;position:relative}
|
||||
.loading-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(255,255,255,.85);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:9999}
|
||||
.spinner{width:40px;height:40px;border:4px solid var(--pink-light);border-top-color:var(--pink);border-radius:50%;animation:spin .8s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.loading-overlay p{margin-top:12px;color:var(--pink);font-size:14px}
|
||||
|
||||
/* Header */
|
||||
.header{background:linear-gradient(135deg,var(--pink),#c2185b);color:#fff;padding:20px;text-align:center;position:sticky;top:0;z-index:100}
|
||||
.header h1{font-size:20px;font-weight:600}
|
||||
.header p{font-size:12px;opacity:.85;margin-top:4px}
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar{display:flex;background:#fff;border-bottom:1px solid var(--border);position:sticky;top:72px;z-index:99}
|
||||
.tab-bar button{flex:1;padding:12px 0;border:none;background:none;font-size:13px;color:var(--gray);cursor:pointer;border-bottom:2px solid transparent;transition:.2s}
|
||||
.tab-bar button.active{color:var(--pink);border-bottom-color:var(--pink);font-weight:600}
|
||||
|
||||
/* Pages */
|
||||
.page{display:none;padding:16px}
|
||||
.page.active{display:block}
|
||||
|
||||
/* Cards */
|
||||
.card{background:#fff;border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:var(--shadow)}
|
||||
.card h3{font-size:15px;margin-bottom:8px;color:#333}
|
||||
.card p{font-size:13px;color:#666;line-height:1.6}
|
||||
|
||||
/* Upload area */
|
||||
.upload-area{border:2px dashed #ddd;border-radius:var(--radius);padding:40px 20px;text-align:center;cursor:pointer;transition:.2s;background:#fafafa}
|
||||
.upload-area:hover{border-color:var(--pink);background:var(--pink-bg)}
|
||||
.upload-area .icon{font-size:48px;margin-bottom:12px}
|
||||
.upload-area p{color:var(--gray);font-size:14px}
|
||||
.upload-area input{display:none}
|
||||
|
||||
/* Image preview */
|
||||
.preview-img{max-width:100%;border-radius:8px;margin:12px 0}
|
||||
|
||||
/* Status badges */
|
||||
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:500}
|
||||
.badge-normal{background:#e8f5e9;color:var(--green)}
|
||||
.badge-attention{background:#fff3e0;color:var(--orange)}
|
||||
.badge-warning{background:#ffebee;color:var(--red)}
|
||||
.badge-high{background:#ffebee;color:var(--red)}
|
||||
.badge-low{background:#fff3e0;color:var(--orange)}
|
||||
|
||||
/* Buttons */
|
||||
.btn{display:inline-block;padding:10px 24px;border-radius:24px;border:none;font-size:14px;cursor:pointer;transition:.2s;font-weight:500}
|
||||
.btn-primary{background:var(--pink);color:#fff}
|
||||
.btn-primary:hover{background:#c2185b}
|
||||
.btn-primary:disabled{background:#ccc;cursor:not-allowed}
|
||||
.btn-outline{border:1px solid var(--pink);color:var(--pink);background:none}
|
||||
.btn-block{display:block;width:100%;text-align:center}
|
||||
|
||||
/* Data items table */
|
||||
.data-table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
|
||||
.data-table th{text-align:left;padding:8px 6px;border-bottom:2px solid var(--border);color:var(--gray);font-weight:500}
|
||||
.data-table td{padding:8px 6px;border-bottom:1px solid var(--border)}
|
||||
|
||||
/* Chat */
|
||||
.chat-container{display:flex;flex-direction:column;height:calc(100vh - 170px)}
|
||||
.chat-messages{flex:1;overflow-y:auto;padding:12px 0}
|
||||
.chat-msg{margin-bottom:12px;display:flex}
|
||||
.chat-msg.user{justify-content:flex-end}
|
||||
.chat-bubble{max-width:80%;padding:10px 14px;border-radius:16px;font-size:14px;line-height:1.6;white-space:pre-wrap}
|
||||
.chat-msg.user .chat-bubble{background:var(--pink);color:#fff;border-bottom-right-radius:4px}
|
||||
.chat-msg.assistant .chat-bubble{background:#fff;color:#333;border-bottom-left-radius:4px;box-shadow:var(--shadow)}
|
||||
.chat-input-area{display:flex;gap:8px;padding:12px 0}
|
||||
.chat-input-area input{flex:1;padding:10px 16px;border:1px solid #ddd;border-radius:24px;font-size:14px;outline:none}
|
||||
.chat-input-area input:focus{border-color:var(--pink)}
|
||||
.quick-questions{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
|
||||
.quick-q{padding:6px 12px;background:var(--pink-bg);color:var(--pink);border-radius:16px;font-size:12px;cursor:pointer;border:none;transition:.2s}
|
||||
.quick-q:hover{background:var(--pink-light)}
|
||||
|
||||
/* Report list */
|
||||
.report-item{display:flex;align-items:center;padding:12px;border-bottom:1px solid var(--border);cursor:pointer;transition:.1s}
|
||||
.report-item:hover{background:#fafafa}
|
||||
.report-item .info{flex:1;margin-left:12px}
|
||||
.report-item .info h4{font-size:14px;margin-bottom:4px}
|
||||
.report-item .info p{font-size:12px;color:var(--gray)}
|
||||
.report-icon{width:40px;height:40px;border-radius:10px;background:var(--pink-light);display:flex;align-items:center;justify-content:center;font-size:20px}
|
||||
|
||||
/* Analysis result */
|
||||
.analysis-section{margin-bottom:16px}
|
||||
.analysis-section h4{font-size:14px;margin-bottom:8px;color:var(--pink)}
|
||||
.indicator-card{background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px}
|
||||
.indicator-card .name{font-weight:600;font-size:14px}
|
||||
.indicator-card .nickname{color:var(--pink);font-size:12px;margin-left:6px}
|
||||
.indicator-card .detail{font-size:13px;color:#555;margin-top:6px;line-height:1.6}
|
||||
|
||||
/* Action items */
|
||||
.action-item{display:flex;align-items:flex-start;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
|
||||
.action-dot{width:8px;height:8px;border-radius:50%;margin-top:5px;flex-shrink:0}
|
||||
.action-dot.high{background:var(--red)}
|
||||
.action-dot.medium{background:var(--orange)}
|
||||
.action-dot.low{background:var(--green)}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state{text-align:center;padding:60px 20px;color:var(--gray)}
|
||||
.empty-state .icon{font-size:64px;margin-bottom:16px}
|
||||
|
||||
/* Verify form */
|
||||
.verify-item{display:flex;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap}
|
||||
.verify-item input,.verify-item select{padding:6px 10px;border:1px solid #ddd;border-radius:6px;font-size:13px}
|
||||
.verify-item input{width:80px}
|
||||
.verify-item .item-name{font-size:13px;font-weight:500;min-width:100px}
|
||||
textarea{width:100%;padding:10px;border:1px solid #ddd;border-radius:8px;font-size:13px;resize:vertical;min-height:60px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="loading-overlay" id="globalLoading" style="display:none">
|
||||
<div class="spinner"></div>
|
||||
<p id="loadingText">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<h1>孕育无忧</h1>
|
||||
<p>AI 智能解读孕期检查报告</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button class="active" onclick="showPage('home')">首页</button>
|
||||
<button onclick="showPage('reports')">报告</button>
|
||||
<button onclick="showPage('chat')">AI问答</button>
|
||||
<button onclick="showPage('me')">我的</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== 首页 ===== -->
|
||||
<div class="page active" id="page-home">
|
||||
<div class="card">
|
||||
<h3>📸 上传检查报告</h3>
|
||||
<p style="margin-bottom:12px">拍照或选择孕期检查报告图片,AI 自动识别并解读</p>
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<div class="icon">📋</div>
|
||||
<p>点击上传报告图片</p>
|
||||
<p style="font-size:12px;margin-top:4px;color:#bbb">支持 JPG、PNG 格式</p>
|
||||
<input type="file" id="fileInput" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ocrResult" style="display:none">
|
||||
<div class="card">
|
||||
<h3>📝 OCR 识别结果</h3>
|
||||
<p style="margin-bottom:8px;color:var(--gray);font-size:12px">请核对以下识别数据,如有误请修正后确认</p>
|
||||
<img id="previewImg" class="preview-img" alt="报告图片">
|
||||
<div id="ocrDataItems"></div>
|
||||
<div style="margin-top:12px">
|
||||
<label style="font-size:13px;font-weight:500">自觉症状(可选)</label>
|
||||
<textarea id="symptomsInput" placeholder="如:头晕、水肿、腰痛等..." style="margin-top:6px"></textarea>
|
||||
</div>
|
||||
<div style="margin-top:12px;text-align:center">
|
||||
<button class="btn btn-primary btn-block" onclick="verifyReport()">确认数据,生成 AI 解读</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="analysisResult" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 报告列表 ===== -->
|
||||
<div class="page" id="page-reports">
|
||||
<div class="card">
|
||||
<h3>📋 我的报告</h3>
|
||||
<div id="reportList">
|
||||
<div class="empty-state">
|
||||
<div class="icon">📭</div>
|
||||
<p>暂无报告,去首页上传吧</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== AI 对话 ===== -->
|
||||
<div class="page" id="page-chat">
|
||||
<div class="chat-container">
|
||||
<div id="chatReportSelect" class="card" style="padding:10px 14px">
|
||||
<label style="font-size:13px;color:var(--gray)">选择报告进行追问:</label>
|
||||
<select id="chatReportId" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;margin-top:6px;font-size:13px">
|
||||
<option value="">请先上传报告</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="quickQuestions" class="quick-questions"></div>
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
<div class="empty-state">
|
||||
<div class="icon">💬</div>
|
||||
<p>选择报告后可以向 AI 追问</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<input type="text" id="chatInput" placeholder="输入你的问题..." onkeydown="if(event.key==='Enter')sendChat()">
|
||||
<button class="btn btn-primary" onclick="sendChat()">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 我的 ===== -->
|
||||
<div class="page" id="page-me">
|
||||
<div class="card" style="text-align:center;padding:30px">
|
||||
<div style="width:64px;height:64px;border-radius:50%;background:var(--pink-light);margin:0 auto 12px;display:flex;align-items:center;justify-content:center;font-size:28px">👩</div>
|
||||
<h3 id="userName">体验用户</h3>
|
||||
<p style="color:var(--gray);margin-top:4px" id="userInfo">加载中...</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>🍎 营养建议</h3>
|
||||
<p style="margin-bottom:12px;font-size:13px;color:var(--gray)">根据报告选择对应的营养建议</p>
|
||||
<select id="nutritionReportId" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px">
|
||||
<option value="">选择报告</option>
|
||||
</select>
|
||||
<button class="btn btn-outline btn-block" style="margin-top:8px" onclick="getNutrition()">获取营养建议</button>
|
||||
<div id="nutritionResult" style="margin-top:12px"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>🏃♀️ 运动建议</h3>
|
||||
<button class="btn btn-outline btn-block" onclick="getExercise()">获取运动建议</button>
|
||||
<div id="exerciseResult" style="margin-top:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
let TOKEN = '';
|
||||
let currentReportId = '';
|
||||
let ocrDataItems = [];
|
||||
|
||||
// ---- Helpers ----
|
||||
function showLoading(text='加载中...'){
|
||||
document.getElementById('loadingText').textContent=text;
|
||||
document.getElementById('globalLoading').style.display='flex';
|
||||
}
|
||||
function hideLoading(){document.getElementById('globalLoading').style.display='none'}
|
||||
|
||||
async function api(path,opts={}){
|
||||
const headers = {'Authorization':'Bearer '+TOKEN, ...(opts.headers||{})};
|
||||
if(opts.body && !(opts.body instanceof FormData)){
|
||||
headers['Content-Type']='application/json';
|
||||
opts.body=JSON.stringify(opts.body);
|
||||
}
|
||||
const res = await fetch(API+path, {...opts, headers});
|
||||
if(!res.ok){
|
||||
const err = await res.json().catch(()=>({detail:'请求失败'}));
|
||||
throw new Error(err.detail||'请求失败');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function showPage(name){
|
||||
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
||||
document.getElementById('page-'+name).classList.add('active');
|
||||
document.querySelectorAll('.tab-bar button').forEach((b,i)=>{
|
||||
b.classList.toggle('active', ['home','reports','chat','me'][i]===name);
|
||||
});
|
||||
if(name==='reports') loadReportList();
|
||||
if(name==='chat') loadChatReportSelect();
|
||||
if(name==='me') loadProfile();
|
||||
}
|
||||
|
||||
function badgeHtml(status){
|
||||
const map={normal:'正常',attention:'注意',warning:'警告',high:'偏高',low:'偏低'};
|
||||
return `<span class="badge badge-${status}">${map[status]||status}</span>`;
|
||||
}
|
||||
|
||||
// ---- Auth ----
|
||||
async function demoLogin(){
|
||||
showLoading('正在登录...');
|
||||
try{
|
||||
const data = await fetch(API+'/api/auth/demo-login',{method:'POST'}).then(r=>r.json());
|
||||
TOKEN = data.access_token;
|
||||
hideLoading();
|
||||
}catch(e){
|
||||
hideLoading();
|
||||
alert('登录失败:'+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Upload ----
|
||||
document.getElementById('uploadArea').onclick=()=>document.getElementById('fileInput').click();
|
||||
document.getElementById('fileInput').onchange=async function(){
|
||||
const file = this.files[0];
|
||||
if(!file) return;
|
||||
showLoading('AI 正在识别报告...');
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try{
|
||||
const data = await api('/api/report/upload',{method:'POST',body:fd});
|
||||
currentReportId = data.report_id;
|
||||
ocrDataItems = data.ocr_result.data_items || [];
|
||||
|
||||
// Show preview
|
||||
document.getElementById('previewImg').src = API + '/' + data.image_url;
|
||||
renderOcrItems(ocrDataItems, data.ocr_result.report_type);
|
||||
document.getElementById('ocrResult').style.display='block';
|
||||
document.getElementById('analysisResult').style.display='none';
|
||||
hideLoading();
|
||||
}catch(e){
|
||||
hideLoading();
|
||||
alert('上传失败:'+e.message);
|
||||
}
|
||||
};
|
||||
|
||||
function renderOcrItems(items, reportType){
|
||||
let html = `<div class="verify-item" style="margin-bottom:12px">
|
||||
<span class="item-name">报告类型</span>
|
||||
<input id="reportTypeInput" value="${reportType||''}" style="flex:1" placeholder="如:血常规">
|
||||
</div>`;
|
||||
html += '<table class="data-table"><thead><tr><th>指标</th><th>数值</th><th>单位</th><th>参考范围</th><th>状态</th></tr></thead><tbody>';
|
||||
items.forEach((item,i)=>{
|
||||
html += `<tr>
|
||||
<td><input data-idx="${i}" data-field="name" value="${item.name||''}" style="width:80px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
|
||||
<td><input data-idx="${i}" data-field="value" value="${item.value||''}" style="width:60px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
|
||||
<td><input data-idx="${i}" data-field="unit" value="${item.unit||''}" style="width:50px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
|
||||
<td><input data-idx="${i}" data-field="reference_range" value="${item.reference_range||''}" style="width:80px;border:1px solid #eee;padding:4px;border-radius:4px"></td>
|
||||
<td><select data-idx="${i}" data-field="status" style="border:1px solid #eee;padding:4px;border-radius:4px">
|
||||
<option value="normal" ${item.status==='normal'?'selected':''}>正常</option>
|
||||
<option value="high" ${item.status==='high'?'selected':''}>偏高</option>
|
||||
<option value="low" ${item.status==='low'?'selected':''}>偏低</option>
|
||||
</select></td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('ocrDataItems').innerHTML = html;
|
||||
}
|
||||
|
||||
async function verifyReport(){
|
||||
// Collect edited data
|
||||
const items = ocrDataItems.map((_,i)=>{
|
||||
const get = f => document.querySelector(`[data-idx="${i}"][data-field="${f}"]`).value;
|
||||
return {name:get('name'),value:get('value'),unit:get('unit'),reference_range:get('reference_range'),status:get('status'),category:''};
|
||||
});
|
||||
const reportType = document.getElementById('reportTypeInput').value;
|
||||
const symptoms = document.getElementById('symptomsInput').value;
|
||||
|
||||
showLoading('正在确认数据...');
|
||||
try{
|
||||
await api(`/api/report/${currentReportId}/verify`,{
|
||||
method:'PUT',
|
||||
body:{data_items:items,symptoms:symptoms||null,notes:null,report_type:reportType||null}
|
||||
});
|
||||
hideLoading();
|
||||
generateAnalysis();
|
||||
}catch(e){
|
||||
hideLoading();
|
||||
alert('确认失败:'+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAnalysis(){
|
||||
showLoading('AI 正在解读报告,请稍候...');
|
||||
try{
|
||||
const data = await api(`/api/analysis/${currentReportId}/generate`,{method:'POST'});
|
||||
renderAnalysis(data);
|
||||
hideLoading();
|
||||
}catch(e){
|
||||
hideLoading();
|
||||
alert('解读失败:'+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnalysis(data){
|
||||
let html = '<div class="card"><h3>🤖 AI 解读报告</h3>';
|
||||
// Overall
|
||||
html += `<div class="analysis-section">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
${badgeHtml(data.overall_status)}
|
||||
<span style="font-size:13px;color:var(--gray)">风险等级:${({low:'低',medium:'中',high:'高'})[data.risk_level]||data.risk_level}</span>
|
||||
</div>
|
||||
<p style="font-size:14px;line-height:1.6">${data.overall_summary}</p>
|
||||
<p style="font-size:14px;color:var(--pink);margin-top:6px">${data.emotional_summary}</p>
|
||||
</div>`;
|
||||
|
||||
// Categories
|
||||
if(data.categories && data.categories.length){
|
||||
data.categories.forEach(cat=>{
|
||||
html += `<div class="analysis-section">
|
||||
<h4>${cat.category} ${badgeHtml(cat.status)}</h4>`;
|
||||
(cat.indicators||[]).forEach(ind=>{
|
||||
html += `<div class="indicator-card">
|
||||
<div><span class="name">${ind.name}</span><span class="nickname">${ind.nickname||''}</span> ${badgeHtml(ind.status)} <span style="font-size:12px;color:var(--gray);margin-left:6px">${ind.value||''}</span></div>
|
||||
<div class="detail">
|
||||
${ind.what_is_it?'<div>📖 '+ind.what_is_it+'</div>':''}
|
||||
${ind.impact?'<div>👶 '+ind.impact+'</div>':''}
|
||||
${ind.suggestion?'<div>💡 '+ind.suggestion+'</div>':''}
|
||||
${ind.recheck_reminder?'<div>🔔 '+ind.recheck_reminder+'</div>':''}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Action items
|
||||
if(data.action_items && data.action_items.length){
|
||||
html += '<div class="analysis-section"><h4>📋 行动建议</h4>';
|
||||
data.action_items.forEach(a=>{
|
||||
html += `<div class="action-item"><div class="action-dot ${a.priority}"></div><div>
|
||||
<div style="font-size:13px">${a.content}</div>
|
||||
<div style="font-size:11px;color:var(--gray)">${a.category||''}</div>
|
||||
</div></div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Tips
|
||||
if(data.tips && data.tips.length){
|
||||
html += '<div class="analysis-section"><h4>💝 温馨提示</h4>';
|
||||
data.tips.forEach(t=>{html += `<p style="font-size:13px;padding:4px 0;color:#555">• ${t}</p>`});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += `<div style="margin-top:12px;display:flex;gap:8px">
|
||||
<button class="btn btn-outline" style="flex:1" onclick="showPage('chat')">💬 追问 AI</button>
|
||||
<button class="btn btn-outline" style="flex:1" onclick="getDoctorQuestions()">🩺 问诊清单</button>
|
||||
</div>`;
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('analysisResult').innerHTML = html;
|
||||
document.getElementById('analysisResult').style.display = 'block';
|
||||
}
|
||||
|
||||
// ---- Report List ----
|
||||
async function loadReportList(){
|
||||
try{
|
||||
const data = await api('/api/report/list');
|
||||
if(!data.length){
|
||||
document.getElementById('reportList').innerHTML='<div class="empty-state"><div class="icon">📭</div><p>暂无报告,去首页上传吧</p></div>';
|
||||
return;
|
||||
}
|
||||
let html='';
|
||||
data.forEach(r=>{
|
||||
const date = new Date(r.created_at).toLocaleDateString('zh-CN');
|
||||
html += `<div class="report-item" onclick="viewReport('${r.id}')">
|
||||
<div class="report-icon">📋</div>
|
||||
<div class="info"><h4>${r.report_type||'检查报告'}</h4><p>${date} · 孕${r.pregnancy_week||'?'}周 · ${r.item_count}项指标</p></div>
|
||||
${badgeHtml(r.overall_status)}
|
||||
</div>`;
|
||||
});
|
||||
document.getElementById('reportList').innerHTML=html;
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
async function viewReport(id){
|
||||
currentReportId = id;
|
||||
showLoading('加载报告...');
|
||||
try{
|
||||
const data = await api(`/api/analysis/${id}`);
|
||||
renderAnalysis(data);
|
||||
showPage('home');
|
||||
document.getElementById('ocrResult').style.display='none';
|
||||
document.getElementById('analysisResult').style.display='block';
|
||||
hideLoading();
|
||||
}catch(e){
|
||||
hideLoading();
|
||||
// No analysis yet, show report detail
|
||||
try{
|
||||
const report = await api(`/api/report/${id}`);
|
||||
currentReportId = id;
|
||||
ocrDataItems = report.data_items||[];
|
||||
renderOcrItems(ocrDataItems, report.report_type);
|
||||
showPage('home');
|
||||
document.getElementById('ocrResult').style.display='block';
|
||||
document.getElementById('analysisResult').style.display='none';
|
||||
}catch(e2){alert(e2.message)}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Chat ----
|
||||
async function loadChatReportSelect(){
|
||||
try{
|
||||
const data = await api('/api/report/list');
|
||||
const sel = document.getElementById('chatReportId');
|
||||
sel.innerHTML = data.length ? data.map(r=>`<option value="${r.id}">${r.report_type||'报告'} - ${new Date(r.created_at).toLocaleDateString('zh-CN')}</option>`).join('') : '<option value="">请先上传报告</option>';
|
||||
if(currentReportId) sel.value=currentReportId;
|
||||
loadChatHistory();
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
async function loadChatHistory(){
|
||||
const rid = document.getElementById('chatReportId').value;
|
||||
if(!rid) return;
|
||||
try{
|
||||
const data = await api(`/api/chat/${rid}/history`);
|
||||
const container = document.getElementById('chatMessages');
|
||||
if(!data.messages.length){
|
||||
container.innerHTML='<div class="empty-state" style="padding:30px"><div class="icon">💬</div><p>有任何疑问都可以问我哦~</p></div>';
|
||||
}else{
|
||||
container.innerHTML = data.messages.map(m=>`<div class="chat-msg ${m.role}"><div class="chat-bubble">${m.content}</div></div>`).join('');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
// Quick questions
|
||||
const qqs = data.quick_questions||["这个指标偏高会影响宝宝吗?","我需要注意什么饮食?","什么时候需要复查?"];
|
||||
document.getElementById('quickQuestions').innerHTML = qqs.map(q=>`<button class="quick-q" onclick="askQuick('${q}')">${q}</button>`).join('');
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
document.getElementById('chatReportId').onchange=loadChatHistory;
|
||||
|
||||
function askQuick(q){document.getElementById('chatInput').value=q;sendChat()}
|
||||
|
||||
async function sendChat(){
|
||||
const rid = document.getElementById('chatReportId').value;
|
||||
const input = document.getElementById('chatInput');
|
||||
const msg = input.value.trim();
|
||||
if(!rid||!msg) return;
|
||||
input.value='';
|
||||
|
||||
const container = document.getElementById('chatMessages');
|
||||
// Clear empty state
|
||||
if(container.querySelector('.empty-state')) container.innerHTML='';
|
||||
container.innerHTML+=`<div class="chat-msg user"><div class="chat-bubble">${msg}</div></div>`;
|
||||
container.innerHTML+=`<div class="chat-msg assistant" id="typingIndicator"><div class="chat-bubble">思考中...</div></div>`;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
try{
|
||||
const data = await api(`/api/chat/${rid}/message`,{method:'POST',body:{content:msg}});
|
||||
document.getElementById('typingIndicator').remove();
|
||||
container.innerHTML+=`<div class="chat-msg assistant"><div class="chat-bubble">${data.ai_message.content}</div></div>`;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}catch(e){
|
||||
document.getElementById('typingIndicator').remove();
|
||||
container.innerHTML+=`<div class="chat-msg assistant"><div class="chat-bubble" style="color:var(--red)">回复失败:${e.message}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Profile ----
|
||||
async function loadProfile(){
|
||||
try{
|
||||
const data = await api('/api/user/profile');
|
||||
document.getElementById('userName').textContent = data.name||'体验用户';
|
||||
document.getElementById('userInfo').textContent = `${data.age||'?'}岁 · 孕${data.pregnancy_week||'?'}周${data.pregnancy_day||0}天 · ${data.blood_type||'?'}型血`;
|
||||
// Also update nutrition select
|
||||
const reports = await api('/api/report/list');
|
||||
document.getElementById('nutritionReportId').innerHTML = reports.length ? reports.map(r=>`<option value="${r.id}">${r.report_type||'报告'} - ${new Date(r.created_at).toLocaleDateString('zh-CN')}</option>`).join('') : '<option value="">暂无报告</option>';
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// ---- Nutrition ----
|
||||
async function getNutrition(){
|
||||
const rid = document.getElementById('nutritionReportId').value;
|
||||
if(!rid){alert('请先选择报告');return}
|
||||
showLoading('AI 正在生成营养建议...');
|
||||
try{
|
||||
const data = await api(`/api/nutrition/${rid}`);
|
||||
let html='';
|
||||
(data.recipes||[]).forEach(r=>{
|
||||
html+=`<div style="background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px">
|
||||
<div style="font-weight:600;font-size:14px">🍳 ${r.name}</div>
|
||||
<p style="font-size:12px;color:var(--gray);margin-top:4px">${r.description||''}</p>
|
||||
<p style="font-size:12px;margin-top:4px">⏱ ${r.time||''} · ${r.difficulty||''}</p>
|
||||
<p style="font-size:12px;color:var(--pink);margin-top:4px">📌 ${r.reason||''}</p>
|
||||
${r.ingredients?'<p style="font-size:12px;margin-top:4px">食材:'+r.ingredients.join('、')+'</p>':''}
|
||||
</div>`;
|
||||
});
|
||||
if(data.dietary_tips&&data.dietary_tips.length){
|
||||
html+='<div style="margin-top:8px"><h4 style="font-size:13px;color:var(--orange)">⚠️ 饮食提醒</h4>';
|
||||
data.dietary_tips.forEach(t=>{html+=`<p style="font-size:13px;padding:2px 0">• ${t}</p>`});
|
||||
html+='</div>';
|
||||
}
|
||||
document.getElementById('nutritionResult').innerHTML=html;
|
||||
hideLoading();
|
||||
}catch(e){hideLoading();alert('获取失败:'+e.message)}
|
||||
}
|
||||
|
||||
// ---- Exercise ----
|
||||
async function getExercise(){
|
||||
showLoading('AI 正在生成运动建议...');
|
||||
try{
|
||||
const data = await api('/api/exercise/recommendations');
|
||||
let html='';
|
||||
(data.exercises||[]).forEach(ex=>{
|
||||
html+=`<div style="background:#fafafa;border-radius:8px;padding:12px;margin-bottom:8px">
|
||||
<div style="font-weight:600;font-size:14px">🏃 ${ex.name}</div>
|
||||
<p style="font-size:12px;color:var(--gray);margin-top:4px">${ex.description||''}</p>
|
||||
<p style="font-size:12px;margin-top:4px">⏱ ${ex.duration||''} · 🔥 ${ex.calories||''} · ${ex.difficulty||''}</p>
|
||||
${ex.benefits?'<p style="font-size:12px;color:var(--green);margin-top:4px">✅ '+ex.benefits.join(' · ')+'</p>':''}
|
||||
${ex.precautions?'<p style="font-size:12px;color:var(--orange);margin-top:4px">⚠️ '+ex.precautions.join(' · ')+'</p>':''}
|
||||
</div>`;
|
||||
});
|
||||
if(data.daily_schedule&&data.daily_schedule.length){
|
||||
html+='<div style="margin-top:8px"><h4 style="font-size:13px;color:var(--pink)">📅 每日运动安排</h4>';
|
||||
data.daily_schedule.forEach(s=>{html+=`<p style="font-size:13px;padding:2px 0">${s.time} ${s.activity} (${s.duration})</p>`});
|
||||
html+='</div>';
|
||||
}
|
||||
document.getElementById('exerciseResult').innerHTML=html;
|
||||
hideLoading();
|
||||
}catch(e){hideLoading();alert('获取失败:'+e.message)}
|
||||
}
|
||||
|
||||
// ---- Doctor Questions ----
|
||||
async function getDoctorQuestions(){
|
||||
if(!currentReportId){alert('请先选择报告');return}
|
||||
showLoading('AI 正在生成问诊清单...');
|
||||
try{
|
||||
const data = await api(`/api/doctor/questions/${currentReportId}`);
|
||||
let html='<div class="card"><h3>🩺 问诊清单</h3><p style="font-size:12px;color:var(--gray);margin-bottom:12px">带着这些问题去看医生,沟通更高效</p>';
|
||||
(data.questions||[]).forEach(q=>{
|
||||
html+=`<div class="action-item"><div class="action-dot ${q.priority}"></div><div>
|
||||
<div style="font-size:14px;font-weight:500">"${q.question}"</div>
|
||||
<div style="font-size:12px;color:var(--gray);margin-top:2px">💡 ${q.reason}</div>
|
||||
</div></div>`;
|
||||
});
|
||||
html+='</div>';
|
||||
document.getElementById('analysisResult').innerHTML+=html;
|
||||
hideLoading();
|
||||
}catch(e){hideLoading();alert('获取失败:'+e.message)}
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
demoLogin();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
19
backend/app/utils/helpers.py
Normal file
19
backend/app/utils/helpers.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from typing import Optional
|
||||
|
||||
from beanie import PydanticObjectId
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def parse_object_id(value: str) -> PydanticObjectId:
|
||||
"""Parse a string to PydanticObjectId, raising 400 on invalid format."""
|
||||
try:
|
||||
return PydanticObjectId(value)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="无效的ID格式")
|
||||
|
||||
|
||||
def mask_name(name: Optional[str]) -> str:
|
||||
"""隐私脱敏:姓名"""
|
||||
if not name or len(name) < 2:
|
||||
return name or ""
|
||||
return name[0] + "*" * (len(name) - 1)
|
||||
Reference in New Issue
Block a user