feat: 初始化孕育无忧全栈项目基础代码结构

完成项目初始搭建,包含后端FastAPI+MongoDB服务、前端React+Vite项目的完整基础结构,添加各类配置文件与依赖清单,涵盖核心业务初始框架。
This commit is contained in:
2026-05-21 00:46:48 +08:00
commit 409dabafb5
142 changed files with 31010 additions and 0 deletions

58
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,6 @@
.env
__pycache__
*.pyc
.git
uploads/*
!uploads/.gitkeep

29
backend/.env.example Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
3.11

20
backend/Dockerfile Normal file
View 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
View File

0
backend/app/__init__.py Normal file
View File

39
backend/app/config.py Normal file
View 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
View 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
View 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")
)

View File

View 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

View File

View 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",
]

View 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)],
]

View 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",
]

View 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",
]

View 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"}},
),
]

View 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

View 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(),
}

View 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,
)

View 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()

View 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 [
"这个指标偏高会影响宝宝吗?",
"我需要注意什么饮食?",
"什么时候需要复查?",
"这个结果严重吗?",
"有什么改善建议?",
]

View 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="医生不存在")

View 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}

View 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,
)

View 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}

View 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)

View File

View 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

View 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

View 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

View 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

View File

View 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
View 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": []}

View 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}"

View 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"),
}

View 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>

View File

View 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
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from backend!")
if __name__ == "__main__":
main()

24
backend/pyproject.toml Normal file
View 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
View 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
View 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,
)

View 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

File diff suppressed because it is too large Load Diff

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

30
frontend/.gitignore vendored Normal file
View 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/

View 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
View 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).

View 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
View 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
View 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.

View 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
View 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

File diff suppressed because it is too large Load Diff

91
frontend/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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} />
)
}

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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,
};

View 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 };

View 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