feat: 初始化孕育无忧全栈项目基础代码结构
完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
This commit is contained in:
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
6
backend/.dockerignore
Normal file
6
backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.git
|
||||||
|
uploads/*
|
||||||
|
!uploads/.gitkeep
|
||||||
29
backend/.env.example
Normal file
29
backend/.env.example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# MongoDB
|
||||||
|
MONGODB_URL=mongodb://localhost:27017
|
||||||
|
MONGODB_DB_NAME=pregnancy_report
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=your-secret-key-change-this
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_EXPIRE_MINUTES=10080
|
||||||
|
|
||||||
|
# WeChat Mini Program
|
||||||
|
WECHAT_APP_ID=your-wechat-app-id
|
||||||
|
WECHAT_APP_SECRET=your-wechat-app-secret
|
||||||
|
|
||||||
|
# Claude AI
|
||||||
|
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||||
|
ANTHROPIC_BASE_URL=
|
||||||
|
AI_MODEL_NAME=claude-sonnet-4-20250514
|
||||||
|
|
||||||
|
# CORS(多个域名逗号分隔,* 表示允许所有)
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
|
|
||||||
|
# Server
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
DEBUG=true
|
||||||
24
backend/.gitignore
vendored
Normal file
24
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
20
backend/Dockerfile
Normal file
20
backend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && pip config set global.trusted-host mirrors.aliyun.com
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/uploads
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
0
backend/README.md
Normal file
0
backend/README.md
Normal file
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)
|
||||||
6
backend/main.py
Normal file
6
backend/main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from backend!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
24
backend/pyproject.toml
Normal file
24
backend/pyproject.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[project]
|
||||||
|
name = "backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"aiofiles==24.1.0",
|
||||||
|
"anthropic==0.42.0",
|
||||||
|
"beanie==1.27.0",
|
||||||
|
"fastapi==0.115.6",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"motor==3.6.0",
|
||||||
|
"openai==1.58.1",
|
||||||
|
"passlib[bcrypt]==1.7.4",
|
||||||
|
"pillow==11.1.0",
|
||||||
|
"pydantic-settings==2.7.1",
|
||||||
|
"pydantic[email]==2.10.4",
|
||||||
|
"python-dotenv==1.0.1",
|
||||||
|
"python-jose[cryptography]==3.3.0",
|
||||||
|
"python-magic==0.4.27",
|
||||||
|
"python-multipart==0.0.19",
|
||||||
|
"uvicorn[standard]==0.34.0",
|
||||||
|
]
|
||||||
32
backend/requirements.txt
Normal file
32
backend/requirements.txt
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# FastAPI
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
python-multipart==0.0.19
|
||||||
|
|
||||||
|
# Database
|
||||||
|
motor==3.6.0
|
||||||
|
beanie==1.27.0
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
|
||||||
|
# HTTP Client
|
||||||
|
httpx==0.28.1
|
||||||
|
|
||||||
|
# AI
|
||||||
|
anthropic==0.42.0
|
||||||
|
openai==1.58.1
|
||||||
|
|
||||||
|
# Storage / File
|
||||||
|
python-magic==0.4.27
|
||||||
|
Pillow==11.1.0
|
||||||
|
aiofiles==24.1.0
|
||||||
|
|
||||||
|
# Utils
|
||||||
|
pydantic[email]==2.10.4
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
# (included in fastapi)
|
||||||
10
backend/run.py
Normal file
10
backend/run.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import uvicorn
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
reload=settings.debug,
|
||||||
|
)
|
||||||
47
backend/scripts/cleanup_demo_users.py
Normal file
47
backend/scripts/cleanup_demo_users.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""
|
||||||
|
清理旧的共享 demo_web_user 及其关联数据。
|
||||||
|
|
||||||
|
部署新版 demo 登录逻辑后运行一次即可:
|
||||||
|
python -m scripts.cleanup_demo_users
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
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 main():
|
||||||
|
settings = Settings()
|
||||||
|
client = AsyncIOMotorClient(settings.mongodb_url)
|
||||||
|
db = client[settings.database_name]
|
||||||
|
await init_beanie(database=db, document_models=[User, Report, Analysis, ChatSession, CheckupPlan])
|
||||||
|
|
||||||
|
user = await User.find_one(User.openid == "demo_web_user")
|
||||||
|
if not user:
|
||||||
|
print("未找到 demo_web_user,无需清理。")
|
||||||
|
return
|
||||||
|
|
||||||
|
uid = user.id
|
||||||
|
r = await Report.find(Report.user_id == uid).delete()
|
||||||
|
a = await Analysis.find(Analysis.user_id == uid).delete()
|
||||||
|
c = await ChatSession.find(ChatSession.user_id == uid).delete()
|
||||||
|
p = await CheckupPlan.find(CheckupPlan.user_id == uid).delete()
|
||||||
|
await user.delete()
|
||||||
|
|
||||||
|
print(f"已清理 demo_web_user (id={uid}):")
|
||||||
|
print(f" Reports: {r.deleted_count if r else 0}")
|
||||||
|
print(f" Analyses: {a.deleted_count if a else 0}")
|
||||||
|
print(f" ChatSessions: {c.deleted_count if c else 0}")
|
||||||
|
print(f" CheckupPlans: {p.deleted_count if p else 0}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
1215
backend/uv.lock
generated
Normal file
1215
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
30
frontend/.gitignore
vendored
Normal file
30
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
256
frontend/APPLICATION_FLOW.md
Normal file
256
frontend/APPLICATION_FLOW.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# 孕期健康检查报告解读应用 - 完整流程
|
||||||
|
|
||||||
|
## 📱 应用架构概览
|
||||||
|
|
||||||
|
本应用包含三个核心模块,共7个页面,完整覆盖从用户登录到AI报告解读的全流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 模块一:登录与初始化
|
||||||
|
|
||||||
|
### 1. 登录页面 (LoginPage)
|
||||||
|
**路由:** `/`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 支持微信、手机号、Apple ID 快捷登录
|
||||||
|
- 温馨的欢迎文案和视觉设计
|
||||||
|
- 隐私保护说明
|
||||||
|
|
||||||
|
**导航:**
|
||||||
|
- 登录成功后 → `/onboarding` (首次用户)
|
||||||
|
- 登录成功后 → `/home` (老用户)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 用户画像页面 (OnboardingPage)
|
||||||
|
**路由:** `/onboarding`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 收集孕周信息(末次月经日期 / 直接输入孕周)
|
||||||
|
- 收集预产期
|
||||||
|
- 收集孕妈基本信息(年龄、身高、体重等)
|
||||||
|
- 情感化设计,温馨的引导文案
|
||||||
|
|
||||||
|
**导航:**
|
||||||
|
- 完成设置后 → `/home`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 模块二:报告采集与录入
|
||||||
|
|
||||||
|
### 3. 首页 (HomePage)
|
||||||
|
**路由:** `/home`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 显示当前孕周信息
|
||||||
|
- 快捷操作:拍照录入、趋势分析
|
||||||
|
- 报告列表展示(带三色预警标识)
|
||||||
|
- 孕期小贴士
|
||||||
|
- 浮动"+"按钮快速添加报告
|
||||||
|
|
||||||
|
**导航:**
|
||||||
|
- 点击"拍照录入"按钮 → `/camera`
|
||||||
|
- 点击浮动"+"按钮 → `/camera`
|
||||||
|
- 点击报告列表项 → `/analysis/:reportId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. OCR 拍照页面 (CameraCapturePage)
|
||||||
|
**路由:** `/camera`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 实时相机预览
|
||||||
|
- 智能取景框引导
|
||||||
|
- 拍摄提示(光线、角度、防抖)
|
||||||
|
- AI OCR 识别提示
|
||||||
|
- 隐私自动脱敏说明
|
||||||
|
- 重拍/确认功能
|
||||||
|
- 相册导入选项
|
||||||
|
|
||||||
|
**导航:**
|
||||||
|
- 点击返回 → `/home`
|
||||||
|
- 确认拍照后 → `/review` (数据校对页面)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 数据校对页面 (DataVerificationPage)
|
||||||
|
**路由:** `/review` 或 `/verify`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- OCR识别结果展示
|
||||||
|
- 分类折叠展示(血液检查、激素水平等)
|
||||||
|
- Inline 编辑功能(点击即改)
|
||||||
|
- 三色预警标识(正常/注意/异常)
|
||||||
|
- 补充录入区域(自觉症状、备注)
|
||||||
|
- 错误提示和修正引导
|
||||||
|
- 隐私脱敏数据显示(姓名、身份证等)
|
||||||
|
|
||||||
|
**导航:**
|
||||||
|
- 点击返回 → `/camera`
|
||||||
|
- 点击"完成校对,生成解读" → `/analysis/:reportId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 模块三:AI 解读
|
||||||
|
|
||||||
|
### 6. AI 智能解读页面 (AIAnalysisPage)
|
||||||
|
**路由:** `/analysis/:reportId`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 整体评估卡片(三色状态、综合风险评级)
|
||||||
|
- 分类详细解读
|
||||||
|
- 血液指标、激素水平等
|
||||||
|
- 每项指标包含:
|
||||||
|
- 术语白话化(如"血液中的'氧气快递员'")
|
||||||
|
- 通俗解释(这是什么?)
|
||||||
|
- 影响说明(对宝宝的影响)
|
||||||
|
- 行动建议(建议措施)
|
||||||
|
- 复查提醒
|
||||||
|
- 行动建议列表(优先级排序:高/中/低)
|
||||||
|
- 温馨提示
|
||||||
|
- 情感化文案("宝宝发育良好"而非"正常")
|
||||||
|
|
||||||
|
**导航:**
|
||||||
|
- 点击返回 → `/home`
|
||||||
|
- 点击"查看原始报告" → `/report/:reportId`
|
||||||
|
- 分享/下载功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 报告详情页面 (ReportDetailPage)
|
||||||
|
**路由:** `/report/:reportId`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 完整报告数据表格展示
|
||||||
|
- 原始检查数据
|
||||||
|
- 参考范围对比
|
||||||
|
- 历史趋势图表(如有)
|
||||||
|
- 打印/导出功能
|
||||||
|
|
||||||
|
**导航:**
|
||||||
|
- 点击返回 → `/analysis/:reportId`
|
||||||
|
- 点击"AI解读" → `/analysis/:reportId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 完整用户流程示例
|
||||||
|
|
||||||
|
### 新用户首次使用流程:
|
||||||
|
```
|
||||||
|
1. 打开应用 (/)
|
||||||
|
↓
|
||||||
|
2. 选择登录方式(微信/手机/Apple ID)
|
||||||
|
↓
|
||||||
|
3. 进入用户画像设置 (/onboarding)
|
||||||
|
- 输入孕周信息
|
||||||
|
- 输入个人信息
|
||||||
|
↓
|
||||||
|
4. 进入首页 (/home)
|
||||||
|
- 查看孕周卡片
|
||||||
|
- 浏览孕期小贴士
|
||||||
|
↓
|
||||||
|
5. 点击"拍照录入"或浮动"+" (/camera)
|
||||||
|
- 拍摄检查报告
|
||||||
|
- AI OCR 识别
|
||||||
|
↓
|
||||||
|
6. 数据校对 (/review)
|
||||||
|
- 检查识别结果
|
||||||
|
- 修正错误数据
|
||||||
|
- 补充症状信息
|
||||||
|
↓
|
||||||
|
7. 生成AI解读 (/analysis/1)
|
||||||
|
- 查看整体评估
|
||||||
|
- 阅读详细解读
|
||||||
|
- 了解行动建议
|
||||||
|
↓
|
||||||
|
8. (可选)查看原始报告 (/report/1)
|
||||||
|
- 查看完整数据表格
|
||||||
|
```
|
||||||
|
|
||||||
|
### 老用户日常使用流程:
|
||||||
|
```
|
||||||
|
1. 打开应用 (/)
|
||||||
|
↓
|
||||||
|
2. 自动登录跳转到首页 (/home)
|
||||||
|
↓
|
||||||
|
3. 查看历史报告列表
|
||||||
|
↓
|
||||||
|
4. 点击某个报告查看AI解读 (/analysis/1)
|
||||||
|
↓
|
||||||
|
5. 或添加新报告 (/camera)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 页面完整列表
|
||||||
|
|
||||||
|
| 页面 | 路由 | 所属模块 | 核心功能 |
|
||||||
|
|-----|------|---------|---------|
|
||||||
|
| LoginPage | `/` | 模块一 | 用户登录 |
|
||||||
|
| OnboardingPage | `/onboarding` | 模块一 | 用户画像创建 |
|
||||||
|
| HomePage | `/home` | 模块二 | 报告管理中心 |
|
||||||
|
| CameraCapturePage | `/camera` | 模块二 | OCR 拍照 |
|
||||||
|
| DataVerificationPage | `/review` | 模块二 | 数据校对 |
|
||||||
|
| AIAnalysisPage | `/analysis/:reportId` | 模块三 | AI 智能解读 |
|
||||||
|
| ReportDetailPage | `/report/:reportId` | 模块三 | 原始报告详情 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 情感化设计特色
|
||||||
|
|
||||||
|
1. **温和的视觉语言**
|
||||||
|
- 柔和的渐变色(粉色、紫色、蓝色)
|
||||||
|
- 圆角设计(rounded-2xl, rounded-3xl)
|
||||||
|
- 柔和的阴影效果
|
||||||
|
|
||||||
|
2. **关怀的文案**
|
||||||
|
- "宝宝发育良好" vs "正常值"
|
||||||
|
- "血液中的'氧气快递员'" vs "血红蛋白"
|
||||||
|
- "孕育无忧" vs "报告系统"
|
||||||
|
|
||||||
|
3. **三色预警系统**
|
||||||
|
- 🟢 绿色:正常,宝宝发育良好
|
||||||
|
- 🟠 橙色:需关注,有改善建议
|
||||||
|
- 🔴 红色:异常,需及时就医
|
||||||
|
|
||||||
|
4. **情感化图标**
|
||||||
|
- 💕 心形图标表达关爱
|
||||||
|
- 👶 宝宝图标增加亲近感
|
||||||
|
- 🎉 庆祝图标鼓励积极情绪
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 隐私保护
|
||||||
|
|
||||||
|
- 自动脱敏个人信息(姓名、身份证、电话等)
|
||||||
|
- 本地加密存储
|
||||||
|
- 数据传输安全提示
|
||||||
|
- 隐私保护说明文案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 技术栈
|
||||||
|
|
||||||
|
- **前端框架:** React + TypeScript
|
||||||
|
- **样式方案:** Tailwind CSS v4
|
||||||
|
- **路由管理:** React Router v7
|
||||||
|
- **动画库:** Motion (Framer Motion)
|
||||||
|
- **图标库:** Lucide React
|
||||||
|
- **UI 组件:** 自定义组件库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速测试流程
|
||||||
|
|
||||||
|
1. 访问 `/` - 看到登录页面
|
||||||
|
2. 点击任意登录方式 - 跳转到 `/onboarding`
|
||||||
|
3. 填写用户信息 - 跳转到 `/home`
|
||||||
|
4. 点击"拍照录入" - 跳转到 `/camera`
|
||||||
|
5. 点击拍摄按钮 - 模拟拍照
|
||||||
|
6. 点击"确认使用" - 跳转到 `/review`
|
||||||
|
7. 检查数据并点击"完成校对" - 跳转到 `/analysis/1`
|
||||||
|
8. 查看AI解读 - 可点击"查看原始报告"跳转到 `/report/1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**所有模块都已完整实现!** ✅
|
||||||
3
frontend/ATTRIBUTIONS.md
Normal file
3
frontend/ATTRIBUTIONS.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
|
||||||
|
|
||||||
|
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
|
||||||
383
frontend/COMPLETE_FEATURES.md
Normal file
383
frontend/COMPLETE_FEATURES.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# 🤰 孕期健康检查报告解读应用 - 完整功能列表
|
||||||
|
|
||||||
|
## 📊 应用概览
|
||||||
|
|
||||||
|
**总共 6 个核心模块 | 18 个完整页面 | 全流程闭环**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模块一:登录与初始化 ✅
|
||||||
|
|
||||||
|
### 1. 登录页面 (`/`)
|
||||||
|
- ✅ 微信、手机号、Apple ID 快捷登录
|
||||||
|
- ✅ 温馨的欢迎文案
|
||||||
|
- ✅ 隐私保护说明
|
||||||
|
|
||||||
|
### 2. 用户画像页面 (`/onboarding`)
|
||||||
|
- ✅ 收集孕周信息(末次月经 / 直接输入)
|
||||||
|
- ✅ 收集预产期
|
||||||
|
- ✅ 收集孕妈基本信息
|
||||||
|
- ✅ 情感化设计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模块二:报告采集与录入 ✅
|
||||||
|
|
||||||
|
### 3. 首页 (`/home`)
|
||||||
|
- ✅ 当前孕周信息卡片
|
||||||
|
- ✅ 快捷操作:拍照录入、健康档案、运动方案
|
||||||
|
- ✅ 报告列表(三色预警)
|
||||||
|
- ✅ 孕期小贴士
|
||||||
|
- ✅ 浮动"+"按钮
|
||||||
|
|
||||||
|
### 4. OCR 拍照页面 (`/camera`)
|
||||||
|
- ✅ 实时相机预览
|
||||||
|
- ✅ 智能取景框引导
|
||||||
|
- ✅ 拍摄提示(光线、角度、防抖)
|
||||||
|
- ✅ AI OCR 识别
|
||||||
|
- ✅ 隐私自动脱敏说明
|
||||||
|
- ✅ 重拍/确认功能
|
||||||
|
|
||||||
|
### 5. 数据校对页面 (`/review` 或 `/verify`)
|
||||||
|
- ✅ OCR 识别结果展示
|
||||||
|
- ✅ 分类折叠展示
|
||||||
|
- ✅ Inline 编辑功能
|
||||||
|
- ✅ 三色预警标识
|
||||||
|
- ✅ 补充录入区域
|
||||||
|
- ✅ 隐私脱敏显示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模块三:AI 解读 ✅
|
||||||
|
|
||||||
|
### 6. AI 智能解读页面 (`/analysis/:reportId`)
|
||||||
|
- ✅ 整体评估卡片
|
||||||
|
- ✅ 综合风险评级
|
||||||
|
- ✅ 分类详细解读(血液、激素等)
|
||||||
|
- ✅ 术语白话化(如"氧气快递员")
|
||||||
|
- ✅ 通俗解释(这是什么?)
|
||||||
|
- ✅ 影响说明(对宝宝的影响)
|
||||||
|
- ✅ 行动建议(优先级排序)
|
||||||
|
- ✅ 情感化文案
|
||||||
|
- ✅ **新增:深度服务入口**
|
||||||
|
- AI 对话追问
|
||||||
|
- 饮食建议
|
||||||
|
- 运动方案
|
||||||
|
- 问诊清单
|
||||||
|
- 专家复核
|
||||||
|
|
||||||
|
### 7. 报告详情页面 (`/report/:reportId`)
|
||||||
|
- ✅ 完整报告数据表格
|
||||||
|
- ✅ 原始检查数据
|
||||||
|
- ✅ 参考范围对比
|
||||||
|
- ✅ 打印/导出功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 模块四:AI 深度解读 ✅ **新增**
|
||||||
|
|
||||||
|
### 8. AI 对话追问页面 (`/chat/:reportId`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 对话式 AI 问答
|
||||||
|
- ✅ 针对报告具体指标提问
|
||||||
|
- ✅ 关联医学知识库回答
|
||||||
|
- ✅ 快捷提问建议
|
||||||
|
- ✅ 聊天记录保存
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- 实时对话界面
|
||||||
|
- AI 打字效果
|
||||||
|
- 历史消息展示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. AI 饮食建议页面 (`/nutrition/:reportId`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 基于报告定制食谱
|
||||||
|
- ✅ 详细食材清单
|
||||||
|
- ✅ 制作步骤说明
|
||||||
|
- ✅ 营养成分标注
|
||||||
|
- ✅ 一键收藏功能
|
||||||
|
- ✅ 饮食禁忌提醒
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- 针对性推荐(如补铁食谱)
|
||||||
|
- 孕周适配
|
||||||
|
- 难度分级
|
||||||
|
- 时长预估
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. AI 运动建议页面 (`/exercise`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 适配孕周的运动方案
|
||||||
|
- ✅ 详细动作要领
|
||||||
|
- ✅ 运动益处说明
|
||||||
|
- ✅ 注意事项提醒
|
||||||
|
- ✅ 一键跟练
|
||||||
|
- ✅ 一键收藏
|
||||||
|
- ✅ 作息建议
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- 运动 / 作息双标签
|
||||||
|
- 孕早期专属方案
|
||||||
|
- 强度分级(轻度/中度/高度)
|
||||||
|
- 每日作息时间表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 模块五:健康档案管理 ✅ **新增**
|
||||||
|
|
||||||
|
### 11. 健康档案主页 (`/health-records`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 四大功能入口
|
||||||
|
- 胎儿生长曲线
|
||||||
|
- 报告时间轴
|
||||||
|
- 产检日历
|
||||||
|
- 我的收藏
|
||||||
|
- ✅ 数据概览统计
|
||||||
|
- ✅ 最近活动展示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. 胎儿生长曲线页面 (`/growth-curve`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 自动提取 B 超数据
|
||||||
|
- ✅ 生成发育曲线图(双顶径/腹围/股骨长)
|
||||||
|
- ✅ 对比标准曲线
|
||||||
|
- ✅ 正常范围区域显示
|
||||||
|
- ✅ 详细数据表格
|
||||||
|
- ✅ 分享/导出功能
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- 使用 recharts 可视化
|
||||||
|
- 三个指标切换
|
||||||
|
- 趋势分析
|
||||||
|
- 评估结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. 报告时间轴页面 (`/timeline`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 按孕早/中/晚期分类
|
||||||
|
- ✅ 时间轴展示
|
||||||
|
- ✅ 搜索功能
|
||||||
|
- ✅ 筛选功能
|
||||||
|
- ✅ 统计数据(正常/关注/异常)
|
||||||
|
- ✅ 支持按指标搜索历史变化
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- 可视化时间轴
|
||||||
|
- 月份分组
|
||||||
|
- 三色状态标识
|
||||||
|
- 快速跳转到解读页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. 产检日历页面 (`/calendar`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 根据孕周推送下次产检项目
|
||||||
|
- ✅ 详细检查项目列表
|
||||||
|
- ✅ 注意事项(需空腹/需憋尿)
|
||||||
|
- ✅ 下次产检倒计时
|
||||||
|
- ✅ 设置提醒
|
||||||
|
- ✅ 添加到日历
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- 完整孕期产检计划
|
||||||
|
- 重要度标识
|
||||||
|
- 响应时间提示
|
||||||
|
- 医生提示说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. 收藏清单页面 (`/favorites`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 查看收藏的食谱
|
||||||
|
- ✅ 查看收藏的运动
|
||||||
|
- ✅ 分类筛选(全部/食谱/运动)
|
||||||
|
- ✅ 取消收藏
|
||||||
|
- ✅ 快速跳转到详情
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- 分类展示
|
||||||
|
- 收藏时间记录
|
||||||
|
- 一键跳转
|
||||||
|
- 空状态引导
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 模块六:辅助工具与闭环 ✅ **新增**
|
||||||
|
|
||||||
|
### 16. 问诊清单页面 (`/doctor-questions/:reportId`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 针对报告异常项生成 3-5 条问诊话术
|
||||||
|
- ✅ 优先级排序(重要/建议/了解)
|
||||||
|
- ✅ 一键复制单个问题
|
||||||
|
- ✅ 一键复制全部问题
|
||||||
|
- ✅ 问题原因说明
|
||||||
|
- ✅ 分享/导出 PDF
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- AI 智能生成
|
||||||
|
- 就医沟通话术
|
||||||
|
- 避免紧张忘记
|
||||||
|
- 医学知识关联
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 17. 分享长图页面 (`/share/:reportId`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 生成不含隐私信息的解读长图
|
||||||
|
- ✅ 隐私模式开关
|
||||||
|
- ✅ 包含详细数据开关
|
||||||
|
- ✅ 实时预览
|
||||||
|
- ✅ 生成长图
|
||||||
|
- ✅ 下载图片
|
||||||
|
- ✅ 分享到微信
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- 加密长图
|
||||||
|
- 自动脱敏
|
||||||
|
- 美观设计
|
||||||
|
- 适合社交分享
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 18. 专家复核页面 (`/expert-consultation`)
|
||||||
|
**功能:**
|
||||||
|
- ✅ 真人医生付费解读入口
|
||||||
|
- ✅ 三甲医院主任医师
|
||||||
|
- ✅ 多种咨询方式
|
||||||
|
- 图文咨询(¥50,24 小时回复)
|
||||||
|
- 视频问诊(¥200,预约时间)
|
||||||
|
- 加急咨询(¥100,2 小时回复)
|
||||||
|
- ✅ 医生列表展示
|
||||||
|
- ✅ 医生评分/评价数
|
||||||
|
- ✅ 响应时间显示
|
||||||
|
- ✅ 立即咨询
|
||||||
|
|
||||||
|
**特色:**
|
||||||
|
- AI 无法判定的复杂情况
|
||||||
|
- 专业医生一对一
|
||||||
|
- 价格透明
|
||||||
|
- 咨询流程说明
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 设计亮点
|
||||||
|
|
||||||
|
### 情感化设计
|
||||||
|
- ✅ 温和的视觉语言(柔和渐变、圆角设计)
|
||||||
|
- ✅ 关怀的文案("宝宝发育良好" vs "正常值")
|
||||||
|
- ✅ 三色预警系统(绿色/橙色/红色)
|
||||||
|
- ✅ 情感化图标(❤️💕👶🎉)
|
||||||
|
|
||||||
|
### 交互体验
|
||||||
|
- ✅ 流畅的动画效果(Motion)
|
||||||
|
- ✅ Inline 编辑
|
||||||
|
- ✅ 分类折叠展示
|
||||||
|
- ✅ 渐进式信息披露
|
||||||
|
- ✅ 一键收藏/跟练
|
||||||
|
- ✅ 快捷复制
|
||||||
|
|
||||||
|
### 隐私保护
|
||||||
|
- ✅ 自动脱敏(姓名、身份证、电话)
|
||||||
|
- ✅ 本地加密存储
|
||||||
|
- ✅ 数据传输安全提示
|
||||||
|
- ✅ 分享隐私模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 技术栈
|
||||||
|
|
||||||
|
- **前端框架:** React + TypeScript
|
||||||
|
- **样式方案:** Tailwind CSS v4
|
||||||
|
- **路由管理:** React Router v7
|
||||||
|
- **动画库:** Motion (Framer Motion)
|
||||||
|
- **图表库:** Recharts
|
||||||
|
- **图标库:** Lucide React
|
||||||
|
- **UI 组件:** 自定义组件库(Radix UI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 完整用户流程
|
||||||
|
|
||||||
|
```
|
||||||
|
登录 (/)
|
||||||
|
↓
|
||||||
|
用户画像 (/onboarding)
|
||||||
|
↓
|
||||||
|
首页 (/home)
|
||||||
|
↓
|
||||||
|
【场景 1:添加新报告】
|
||||||
|
拍照 (/camera) → 数据校对 (/review) → AI 解读 (/analysis/1)
|
||||||
|
↓
|
||||||
|
【场景 2:深度解读】
|
||||||
|
AI 对话追问 (/chat/1)
|
||||||
|
饮食建议 (/nutrition/1)
|
||||||
|
运动方案 (/exercise)
|
||||||
|
问诊清单 (/doctor-questions/1)
|
||||||
|
↓
|
||||||
|
【场景 3:健康管理】
|
||||||
|
健康档案 (/health-records)
|
||||||
|
├─ 生长曲线 (/growth-curve)
|
||||||
|
├─ 报告时间轴 (/timeline)
|
||||||
|
├─ 产检日历 (/calendar)
|
||||||
|
└─ 收藏清单 (/favorites)
|
||||||
|
↓
|
||||||
|
【场景 4:分享与咨询】
|
||||||
|
分享长图 (/share/1)
|
||||||
|
专家复核 (/expert-consultation)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心创新点
|
||||||
|
|
||||||
|
1. **情感化设计贯穿始终**
|
||||||
|
- 温和的色彩、圆角设计
|
||||||
|
- 白话化术语、关怀文案
|
||||||
|
- 缓解孕妈焦虑感
|
||||||
|
|
||||||
|
2. **AI + 真人医生闭环**
|
||||||
|
- AI 快速解读
|
||||||
|
- 复杂情况转真人医生
|
||||||
|
- 完整的就医准备(问诊清单)
|
||||||
|
|
||||||
|
3. **深度交互体验**
|
||||||
|
- OCR 高效修改流程
|
||||||
|
- AI 对话式追问
|
||||||
|
- 一键收藏/跟练
|
||||||
|
- 渐进式信息披露
|
||||||
|
|
||||||
|
4. **数据可视化**
|
||||||
|
- 胎儿生长曲线
|
||||||
|
- 报告时间轴
|
||||||
|
- 趋势分析
|
||||||
|
- 产检日历
|
||||||
|
|
||||||
|
5. **个性化服务**
|
||||||
|
- 基于报告的饮食建议
|
||||||
|
- 适配孕周的运动方案
|
||||||
|
- 针对性问诊话术
|
||||||
|
- 定制化 AI 解读
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 待优化项(未来迭代)
|
||||||
|
|
||||||
|
- [ ] 接入真实 OCR API
|
||||||
|
- [ ] 接入真实 AI 模型
|
||||||
|
- [ ] 接入真实医生咨询平台
|
||||||
|
- [ ] 数据持久化(Supabase)
|
||||||
|
- [ ] 消息推送功能
|
||||||
|
- [ ] 社区功能(孕妈交流)
|
||||||
|
- [ ] 多语言支持
|
||||||
|
- [ ] 深色模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**当前状态:** ✅ **所有 6 个模块、18 个页面全部完成!**
|
||||||
|
|
||||||
|
**测试入口:** 访问 `/dev` 页面查看完整导航
|
||||||
20
frontend/Dockerfile
Normal file
20
frontend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Serve stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
# nginx config will be mounted via docker-compose
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
11
frontend/README.md
Normal file
11
frontend/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
# Untitled
|
||||||
|
|
||||||
|
This is a code bundle for Untitled. The original project is available at https://www.figma.com/design/8RgbYsTPkWPmXG6cEdfzSj/Untitled.
|
||||||
|
|
||||||
|
## Running the code
|
||||||
|
|
||||||
|
Run `npm i` to install the dependencies.
|
||||||
|
|
||||||
|
Run `npm run dev` to start the development server.
|
||||||
|
|
||||||
61
frontend/guidelines/Guidelines.md
Normal file
61
frontend/guidelines/Guidelines.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
**Add your own guidelines here**
|
||||||
|
<!--
|
||||||
|
|
||||||
|
System Guidelines
|
||||||
|
|
||||||
|
Use this file to provide the AI with rules and guidelines you want it to follow.
|
||||||
|
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
|
||||||
|
|
||||||
|
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
|
||||||
|
|
||||||
|
# General guidelines
|
||||||
|
|
||||||
|
Any general rules you want the AI to follow.
|
||||||
|
For example:
|
||||||
|
|
||||||
|
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
|
||||||
|
* Refactor code as you go to keep code clean
|
||||||
|
* Keep file sizes small and put helper functions and components in their own files.
|
||||||
|
|
||||||
|
--------------
|
||||||
|
|
||||||
|
# Design system guidelines
|
||||||
|
Rules for how the AI should make generations look like your company's design system
|
||||||
|
|
||||||
|
Additionally, if you select a design system to use in the prompt box, you can reference
|
||||||
|
your design system's components, tokens, variables and components.
|
||||||
|
For example:
|
||||||
|
|
||||||
|
* Use a base font-size of 14px
|
||||||
|
* Date formats should always be in the format “Jun 10”
|
||||||
|
* The bottom toolbar should only ever have a maximum of 4 items
|
||||||
|
* Never use the floating action button with the bottom toolbar
|
||||||
|
* Chips should always come in sets of 3 or more
|
||||||
|
* Don't use a dropdown if there are 2 or fewer options
|
||||||
|
|
||||||
|
You can also create sub sections and add more specific details
|
||||||
|
For example:
|
||||||
|
|
||||||
|
|
||||||
|
## Button
|
||||||
|
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
|
||||||
|
users through the application. It provides visual feedback and clear affordances to enhance user experience.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
|
||||||
|
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
|
||||||
|
|
||||||
|
### Variants
|
||||||
|
* Primary Button
|
||||||
|
* Purpose : Used for the main action in a section or page
|
||||||
|
* Visual Style : Bold, filled with the primary brand color
|
||||||
|
* Usage : One primary button per section to guide users toward the most important action
|
||||||
|
* Secondary Button
|
||||||
|
* Purpose : Used for alternative or supporting actions
|
||||||
|
* Visual Style : Outlined with the primary color, transparent background
|
||||||
|
* Usage : Can appear alongside a primary button for less important actions
|
||||||
|
* Tertiary Button
|
||||||
|
* Purpose : Used for the least important actions
|
||||||
|
* Visual Style : Text-only with no border, using primary color
|
||||||
|
* Usage : For actions that should be available but not emphasized
|
||||||
|
-->
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" translate="no">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="google" content="notranslate" />
|
||||||
|
<title>孕育无忧</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
5517
frontend/package-lock.json
generated
Normal file
5517
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
91
frontend/package.json
Normal file
91
frontend/package.json
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"name": "@figma/my-make-file",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "11.14.0",
|
||||||
|
"@emotion/styled": "11.14.1",
|
||||||
|
"@mui/icons-material": "7.3.5",
|
||||||
|
"@mui/material": "7.3.5",
|
||||||
|
"@popperjs/core": "2.11.8",
|
||||||
|
"@radix-ui/react-accordion": "1.2.3",
|
||||||
|
"@radix-ui/react-alert-dialog": "1.1.6",
|
||||||
|
"@radix-ui/react-aspect-ratio": "1.1.2",
|
||||||
|
"@radix-ui/react-avatar": "1.1.3",
|
||||||
|
"@radix-ui/react-checkbox": "1.1.4",
|
||||||
|
"@radix-ui/react-collapsible": "1.1.3",
|
||||||
|
"@radix-ui/react-context-menu": "2.2.6",
|
||||||
|
"@radix-ui/react-dialog": "1.1.6",
|
||||||
|
"@radix-ui/react-dropdown-menu": "2.1.6",
|
||||||
|
"@radix-ui/react-hover-card": "1.1.6",
|
||||||
|
"@radix-ui/react-label": "2.1.2",
|
||||||
|
"@radix-ui/react-menubar": "1.1.6",
|
||||||
|
"@radix-ui/react-navigation-menu": "1.2.5",
|
||||||
|
"@radix-ui/react-popover": "1.1.6",
|
||||||
|
"@radix-ui/react-progress": "1.1.2",
|
||||||
|
"@radix-ui/react-radio-group": "1.2.3",
|
||||||
|
"@radix-ui/react-scroll-area": "1.2.3",
|
||||||
|
"@radix-ui/react-select": "2.1.6",
|
||||||
|
"@radix-ui/react-separator": "1.1.2",
|
||||||
|
"@radix-ui/react-slider": "1.2.3",
|
||||||
|
"@radix-ui/react-slot": "1.1.2",
|
||||||
|
"@radix-ui/react-switch": "1.1.3",
|
||||||
|
"@radix-ui/react-tabs": "1.1.3",
|
||||||
|
"@radix-ui/react-toggle": "1.1.2",
|
||||||
|
"@radix-ui/react-toggle-group": "1.1.2",
|
||||||
|
"@radix-ui/react-tooltip": "1.1.8",
|
||||||
|
"class-variance-authority": "0.7.1",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"cmdk": "1.1.1",
|
||||||
|
"date-fns": "3.6.0",
|
||||||
|
"embla-carousel-react": "8.6.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
|
"input-otp": "1.4.2",
|
||||||
|
"lucide-react": "0.487.0",
|
||||||
|
"motion": "12.23.24",
|
||||||
|
"next-themes": "0.4.6",
|
||||||
|
"react-day-picker": "8.10.1",
|
||||||
|
"react-dnd": "16.0.1",
|
||||||
|
"react-dnd-html5-backend": "16.0.1",
|
||||||
|
"react-hook-form": "7.55.0",
|
||||||
|
"react-popper": "2.3.0",
|
||||||
|
"react-resizable-panels": "2.1.7",
|
||||||
|
"react-responsive-masonry": "2.7.1",
|
||||||
|
"react-router": "7.13.0",
|
||||||
|
"react-slick": "0.31.0",
|
||||||
|
"recharts": "2.15.2",
|
||||||
|
"sonner": "2.0.3",
|
||||||
|
"tailwind-merge": "3.2.0",
|
||||||
|
"tw-animate-css": "1.3.8",
|
||||||
|
"vaul": "1.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "4.1.12",
|
||||||
|
"@vitejs/plugin-basic-ssl": "^2.2.0",
|
||||||
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
|
"tailwindcss": "4.1.12",
|
||||||
|
"vite": "6.3.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"vite": "6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4304
frontend/pnpm-lock.yaml
generated
Normal file
4304
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/postcss.config.mjs
Normal file
15
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* PostCSS Configuration
|
||||||
|
*
|
||||||
|
* Tailwind CSS v4 (via @tailwindcss/vite) automatically sets up all required
|
||||||
|
* PostCSS plugins — you do NOT need to include `tailwindcss` or `autoprefixer` here.
|
||||||
|
*
|
||||||
|
* This file only exists for adding additional PostCSS plugins, if needed.
|
||||||
|
* For example:
|
||||||
|
*
|
||||||
|
* import postcssNested from 'postcss-nested'
|
||||||
|
* export default { plugins: [postcssNested()] }
|
||||||
|
*
|
||||||
|
* Otherwise, you can leave this file empty.
|
||||||
|
*/
|
||||||
|
export default {}
|
||||||
16
frontend/src/app/App.tsx
Normal file
16
frontend/src/app/App.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { RouterProvider } from "react-router";
|
||||||
|
import { router } from "./routes";
|
||||||
|
import { Toaster } from "./components/ui/sonner";
|
||||||
|
import { UserProvider } from "./context/UserContext";
|
||||||
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<UserProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</UserProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/src/app/components/ErrorBoundary.tsx
Normal file
44
frontend/src/app/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): State {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, textAlign: "center" }}>
|
||||||
|
<p style={{ fontSize: 18, marginBottom: 16 }}>页面加载出错了</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ hasError: false });
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "10px 24px",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "none",
|
||||||
|
background: "#b5a89a",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
刷新重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/src/app/components/MobileLayout.tsx
Normal file
15
frontend/src/app/components/MobileLayout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5 移动端全局布局容器
|
||||||
|
* - max-w-md (448px) 限制最大宽度,大屏居中显示
|
||||||
|
* - min-h-dvh 使用动态视口高度,解决 Safari 地址栏问题
|
||||||
|
* - safe-area padding 适配刘海屏/底部手势条
|
||||||
|
*/
|
||||||
|
export function MobileLayout() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto min-h-dvh relative bg-morandi-50 shadow-[0_0_40px_rgba(0,0,0,0.06)]">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/app/components/figma/ImageWithFallback.tsx
Normal file
27
frontend/src/app/components/figma/ImageWithFallback.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
const ERROR_IMG_SRC =
|
||||||
|
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
|
||||||
|
|
||||||
|
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||||
|
const [didError, setDidError] = useState(false)
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
setDidError(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { src, alt, style, className, ...rest } = props
|
||||||
|
|
||||||
|
return didError ? (
|
||||||
|
<div
|
||||||
|
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
|
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
|
||||||
|
)
|
||||||
|
}
|
||||||
66
frontend/src/app/components/ui/accordion.tsx
Normal file
66
frontend/src/app/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
157
frontend/src/app/components/ui/alert-dialog.tsx
Normal file
157
frontend/src/app/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { buttonVariants } from "./button";
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
66
frontend/src/app/components/ui/alert.tsx
Normal file
66
frontend/src/app/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
11
frontend/src/app/components/ui/aspect-ratio.tsx
Normal file
11
frontend/src/app/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio };
|
||||||
53
frontend/src/app/components/ui/avatar.tsx
Normal file
53
frontend/src/app/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
46
frontend/src/app/components/ui/badge.tsx
Normal file
46
frontend/src/app/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
109
frontend/src/app/components/ui/breadcrumb.tsx
Normal file
109
frontend/src/app/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
58
frontend/src/app/components/ui/button.tsx
Normal file
58
frontend/src/app/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9 rounded-md",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
75
frontend/src/app/components/ui/calendar.tsx
Normal file
75
frontend/src/app/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { buttonVariants } from "./button";
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker>) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row gap-2",
|
||||||
|
month: "flex flex-col gap-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center w-full",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "flex items-center gap-1",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-x-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: cn(
|
||||||
|
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||||
|
props.mode === "range"
|
||||||
|
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||||
|
: "[&:has([aria-selected])]:rounded-md",
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"size-8 p-0 font-normal aria-selected:opacity-100",
|
||||||
|
),
|
||||||
|
day_range_start:
|
||||||
|
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
|
day_range_end:
|
||||||
|
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ className, ...props }) => (
|
||||||
|
<ChevronLeft className={cn("size-4", className)} {...props} />
|
||||||
|
),
|
||||||
|
IconRight: ({ className, ...props }) => (
|
||||||
|
<ChevronRight className={cn("size-4", className)} {...props} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
92
frontend/src/app/components/ui/card.tsx
Normal file
92
frontend/src/app/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<h4
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6 [&:last-child]:pb-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
241
frontend/src/app/components/ui/carousel.tsx
Normal file
241
frontend/src/app/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react";
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
setApi?: (api: CarouselApi) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
|
scrollPrev: () => void;
|
||||||
|
scrollNext: () => void;
|
||||||
|
canScrollPrev: boolean;
|
||||||
|
canScrollNext: boolean;
|
||||||
|
} & CarouselProps;
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins,
|
||||||
|
);
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return;
|
||||||
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
|
setCanScrollNext(api.canScrollNext());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return;
|
||||||
|
setApi(api);
|
||||||
|
}, [api, setApi]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
onSelect(api);
|
||||||
|
api.on("reInit", onSelect);
|
||||||
|
api.on("select", onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect);
|
||||||
|
};
|
||||||
|
}, [api, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
};
|
||||||
353
frontend/src/app/components/ui/chart.tsx
Normal file
353
frontend/src/app/components/ui/chart.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"];
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
32
frontend/src/app/components/ui/checkbox.tsx
Normal file
32
frontend/src/app/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
33
frontend/src/app/components/ui/collapsible.tsx
Normal file
33
frontend/src/app/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
177
frontend/src/app/components/ui/command.tsx
Normal file
177
frontend/src/app/components/ui/command.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "./dialog";
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
252
frontend/src/app/components/ui/context-menu.tsx
Normal file
252
frontend/src/app/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
||||||
135
frontend/src/app/components/ui/dialog.tsx
Normal file
135
frontend/src/app/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
132
frontend/src/app/components/ui/drawer.tsx
Normal file
132
frontend/src/app/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
};
|
||||||
257
frontend/src/app/components/ui/dropdown-menu.tsx
Normal file
257
frontend/src/app/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
168
frontend/src/app/components/ui/form.tsx
Normal file
168
frontend/src/app/components/ui/form.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { Label } from "./label";
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState } = useFormContext();
|
||||||
|
const formState = useFormState({ name: fieldContext.name });
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
};
|
||||||
44
frontend/src/app/components/ui/hover-card.tsx
Normal file
44
frontend/src/app/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||||
77
frontend/src/app/components/ui/input-otp.tsx
Normal file
77
frontend/src/app/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { MinusIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName,
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
21
frontend/src/app/components/ui/input.tsx
Normal file
21
frontend/src/app/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
24
frontend/src/app/components/ui/label.tsx
Normal file
24
frontend/src/app/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
276
frontend/src/app/components/ui/menubar.tsx
Normal file
276
frontend/src/app/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Menubar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Root
|
||||||
|
data-slot="menubar"
|
||||||
|
className={cn(
|
||||||
|
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||||
|
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||||
|
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||||
|
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Trigger
|
||||||
|
data-slot="menubar-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<MenubarPortal>
|
||||||
|
<MenubarPrimitive.Content
|
||||||
|
data-slot="menubar-content"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenubarPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Item
|
||||||
|
data-slot="menubar-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.CheckboxItem
|
||||||
|
data-slot="menubar-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.RadioItem
|
||||||
|
data-slot="menubar-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Label
|
||||||
|
data-slot="menubar-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Separator
|
||||||
|
data-slot="menubar-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="menubar-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||||
|
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.SubTrigger
|
||||||
|
data-slot="menubar-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</MenubarPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.SubContent
|
||||||
|
data-slot="menubar-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
};
|
||||||
168
frontend/src/app/components/ui/navigation-menu.tsx
Normal file
168
frontend/src/app/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
};
|
||||||
127
frontend/src/app/components/ui/pagination.tsx
Normal file
127
frontend/src/app/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
import { Button, buttonVariants } from "./button";
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean;
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">;
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
};
|
||||||
48
frontend/src/app/components/ui/popover.tsx
Normal file
48
frontend/src/app/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
31
frontend/src/app/components/ui/progress.tsx
Normal file
31
frontend/src/app/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
45
frontend/src/app/components/ui/radio-group.tsx
Normal file
45
frontend/src/app/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
|
import { CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
56
frontend/src/app/components/ui/resizable.tsx
Normal file
56
frontend/src/app/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { GripVerticalIcon } from "lucide-react";
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function ResizablePanelGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
data-slot="resizable-panel-group"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizablePanel({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||||
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizableHandle({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
data-slot="resizable-handle"
|
||||||
|
className={cn(
|
||||||
|
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||||
|
<GripVerticalIcon className="size-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
58
frontend/src/app/components/ui/scroll-area.tsx
Normal file
58
frontend/src/app/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
189
frontend/src/app/components/ui/select.tsx
Normal file
189
frontend/src/app/components/ui/select.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
28
frontend/src/app/components/ui/separator.tsx
Normal file
28
frontend/src/app/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator-root"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
139
frontend/src/app/components/ui/sheet.tsx
Normal file
139
frontend/src/app/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "./utils";
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user